RewardServiceTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. using Application.Abstractions.Cache;
  2. using Application.Helpers;
  3. using Domain.Entities.Common;
  4. using Domain.Entities.Common.ValueObject;
  5. using Domain.Entities.Members;
  6. using Domain.Entities.Members.ValueObject;
  7. using Domain.Entities.Wallets;
  8. using Domain.Entities.Wallets.Policy;
  9. using Domain.Entities.Wallets.ValueObject;
  10. using Microsoft.EntityFrameworkCore;
  11. namespace Application.Tests;
  12. [TestClass]
  13. public sealed class RewardServiceTests
  14. {
  15. // 캐시는 항상 miss → DB Config 폴백 경로를 검증한다.
  16. private sealed class NullCacheService : ICacheService
  17. {
  18. public Task<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
  19. public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
  20. public Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask;
  21. public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask;
  22. public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask;
  23. public Task<bool> TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
  24. }
  25. private static async Task SeedConfigAsync(Infrastructure.Persistence.AppDbContext db, RewardConfig reward)
  26. {
  27. var config = Config.Create();
  28. config.UpdateReward(reward);
  29. await db.Config.AddAsync(config);
  30. await db.SaveChangesAsync(default);
  31. }
  32. private static async Task<int> CreateMemberWithWalletAndStatsAsync(Infrastructure.Persistence.AppDbContext db)
  33. {
  34. var member = Member.Create($"reward-{Guid.NewGuid():N}@antooza.test");
  35. await db.Member.AddAsync(member);
  36. await db.SaveChangesAsync(default);
  37. await db.MemberStats.AddAsync(MemberStats.Create(member.ID));
  38. await db.Wallet.AddAsync(Wallet.Create(member.ID));
  39. await db.SaveChangesAsync(default);
  40. return member.ID;
  41. }
  42. [TestMethod]
  43. public async Task GrantForPost_CreditsExpAndToken_WithRewardRefID()
  44. {
  45. using var db = TestDb.Create();
  46. await SeedConfigAsync(db, new RewardConfig { PostExp = 10, PostPoint = 100 });
  47. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  48. await RewardService.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 777, contentLength: 50, default);
  49. await db.SaveChangesAsync(default);
  50. using var verify = TestDb.Create();
  51. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  52. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  53. Assert.AreEqual(100m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 토큰은 Reward 파티션에 적립");
  54. Assert.AreEqual(10, stats.Exp, "게시글 경험치는 MemberStats.Exp 에 적립");
  55. var tx = wallet.Transactions.Single(t => t.RefID == RewardService.PostRefIDFor(777));
  56. Assert.AreEqual(WalletBalanceType.Reward, tx.BalanceType);
  57. Assert.AreEqual(WalletTransactionType.RewardEarned, tx.TxType);
  58. var counter = await verify.MemberDailyRewardCounter.SingleAsync(c => c.MemberID == memberID && c.ActionType == RewardActionType.Post);
  59. Assert.AreEqual(1, counter.Count);
  60. Assert.AreEqual(10, counter.ExpTotal);
  61. Assert.AreEqual(100, counter.PointTotal);
  62. }
  63. [TestMethod]
  64. public async Task GrantForComment_IsIdempotent_NoDoubleCredit()
  65. {
  66. using var db = TestDb.Create();
  67. await SeedConfigAsync(db, new RewardConfig { CommentExp = 5, CommentPoint = 30 });
  68. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  69. await RewardService.GrantForCommentAsync(db, new NullCacheService(), memberID, commentID: 900, contentLength: 20, default);
  70. await db.SaveChangesAsync(default);
  71. // 동일 댓글 재호출 (신규 컨텍스트)
  72. using var db2 = TestDb.Create();
  73. await RewardService.GrantForCommentAsync(db2, new NullCacheService(), memberID, commentID: 900, contentLength: 20, default);
  74. await db2.SaveChangesAsync(default);
  75. using var verify = TestDb.Create();
  76. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  77. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  78. Assert.AreEqual(30m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "동일 댓글 재호출 시 토큰 이중 적립 금지");
  79. Assert.AreEqual(5, stats.Exp, "동일 댓글 재호출 시 경험치 이중 적립 금지");
  80. Assert.AreEqual(1, wallet.Transactions.Count(t => t.RefID == RewardService.CommentRefIDFor(900)), "활동 원장은 정확히 1건");
  81. }
  82. [TestMethod]
  83. public async Task GrantForPost_ZeroConfiguredAmount_GrantsNothing()
  84. {
  85. using var db = TestDb.Create();
  86. await SeedConfigAsync(db, new RewardConfig { PostExp = 0, PostPoint = 0, CommentExp = 5 });
  87. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  88. await RewardService.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 1, contentLength: 50, default);
  89. await db.SaveChangesAsync(default);
  90. using var verify = TestDb.Create();
  91. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  92. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  93. Assert.AreEqual(0m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 보상 0 이면 미지급");
  94. Assert.AreEqual(0, stats.Exp, "게시글 보상 0 이면 경험치 미지급");
  95. Assert.AreEqual(0, wallet.Transactions.Count, "0 지급 시 원장 미기록");
  96. Assert.AreEqual(0, await verify.MemberDailyRewardCounter.CountAsync(c => c.MemberID == memberID), "0 지급 시 카운터 미기록");
  97. }
  98. [TestMethod]
  99. public async Task GrantForPost_BelowMinLength_GrantsNothing()
  100. {
  101. using var db = TestDb.Create();
  102. await SeedConfigAsync(db, new RewardConfig { PostExp = 10, PostPoint = 100, MinPostLength = 30 });
  103. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  104. await RewardService.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 5, contentLength: 10, default);
  105. await db.SaveChangesAsync(default);
  106. using var verify = TestDb.Create();
  107. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  108. Assert.AreEqual(0, stats.Exp, "최소 글자수 미만이면 미지급");
  109. }
  110. [TestMethod]
  111. public async Task GrantForPost_DailyPointCap_ClampsSameDayGrants()
  112. {
  113. using var db = TestDb.Create();
  114. // 게시글당 토큰 100, 일일 토큰 상한 250 → 3번째 게시글은 50 으로 clamp, 4번째는 0(스킵)
  115. await SeedConfigAsync(db, new RewardConfig { PostPoint = 100, PostDailyPoint = 250 });
  116. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  117. for (int postID = 1; postID <= 4; postID++)
  118. {
  119. using var ctx = TestDb.Create();
  120. await RewardService.GrantForPostAsync(ctx, new NullCacheService(), memberID, postID, contentLength: 50, default);
  121. await ctx.SaveChangesAsync(default);
  122. }
  123. using var verify = TestDb.Create();
  124. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  125. Assert.AreEqual(250m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "일일 토큰 상한(250)을 넘겨 적립되면 안 된다");
  126. Assert.AreEqual(3, wallet.Transactions.Count(t => t.TxType == WalletTransactionType.RewardEarned), "상한 도달 후에는 추가 지급되지 않아야 한다");
  127. var counter = await verify.MemberDailyRewardCounter.SingleAsync(c => c.MemberID == memberID && c.ActionType == RewardActionType.Post);
  128. Assert.AreEqual(250, counter.PointTotal, "카운터 누적치도 상한과 일치");
  129. }
  130. [TestMethod]
  131. public async Task GrantForPost_DailyCountCap_SkipsBeyondCount()
  132. {
  133. using var db = TestDb.Create();
  134. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  135. // 하루 2건까지만 경험치 지급. 공유 DB Config 경합 회피 위해 명시 config 로 직접 지급.
  136. var cfg = new RewardConfig { PostExp = 10, PostDailyCount = 2 };
  137. for (int postID = 1; postID <= 3; postID++)
  138. {
  139. using var ctx = TestDb.Create();
  140. await RewardService.GrantAsync(ctx, memberID, RewardActionType.Post, 10, 0, RewardService.PostRefIDFor(920000 + postID), "게시글 작성 보상", cfg, default);
  141. await ctx.SaveChangesAsync(default);
  142. }
  143. using var verify = TestDb.Create();
  144. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  145. var counter = await verify.MemberDailyRewardCounter.SingleAsync(c => c.MemberID == memberID && c.ActionType == RewardActionType.Post);
  146. Assert.AreEqual(20, stats.Exp, "일일 횟수 상한(2)을 넘긴 3번째 글은 미지급");
  147. Assert.AreEqual(2, counter.Count);
  148. }
  149. [TestMethod]
  150. public async Task GrantForPost_AutoPromotesGrade_WhenExpReachesNextRequired()
  151. {
  152. using var db = TestDb.Create();
  153. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  154. var cfg = new RewardConfig { PostExp = 100 };
  155. // 활성 등급: 새싹(0), 중수(50), 고수(500)
  156. var g1 = MemberGrade.Create("새싹", "Sprout", 1, true);
  157. g1.Update("새싹", "Sprout", null, 1, null, null, 0, 0, true);
  158. var g2 = MemberGrade.Create("중수", "Mid", 2, true);
  159. g2.Update("중수", "Mid", null, 2, null, null, 50, 0, true);
  160. var g3 = MemberGrade.Create("고수", "Pro", 3, true);
  161. g3.Update("고수", "Pro", null, 3, null, null, 500, 0, true);
  162. await db.MemberGrade.AddRangeAsync(g1, g2, g3);
  163. await db.SaveChangesAsync(default);
  164. // 100 XP → 중수(50) 충족 (고수 500 은 미충족). 공유 DB Config 경합 회피 위해 명시 config 로 직접 지급.
  165. await RewardService.GrantAsync(db, memberID, RewardActionType.Post, 100, 0, RewardService.PostRefIDFor(910001), "게시글 작성 보상", cfg, default);
  166. await db.SaveChangesAsync(default);
  167. using var verify = TestDb.Create();
  168. var member = await verify.Member.SingleAsync(m => m.ID == memberID);
  169. Assert.AreEqual(g2.ID, member.MemberGradeID, "100 XP 로 중수(RequiredExp=50) 등급으로 자동 승급되어야 한다");
  170. }
  171. [TestMethod]
  172. public async Task GrantForLike_GivesExpToLiker_AndTokenToAuthor_ExcludingSelf()
  173. {
  174. using var db = TestDb.Create();
  175. await SeedConfigAsync(db, new RewardConfig { LikeGivenExp = 3, LikeReceivedPoint = 7 });
  176. var likerID = await CreateMemberWithWalletAndStatsAsync(db);
  177. var authorID = await CreateMemberWithWalletAndStatsAsync(db);
  178. await RewardService.GrantForLikeAsync(db, new NullCacheService(), likerID, authorID, postID: 42, default);
  179. await db.SaveChangesAsync(default);
  180. using var verify = TestDb.Create();
  181. var likerStats = await verify.MemberStats.SingleAsync(s => s.MemberID == likerID);
  182. var authorWallet = await verify.Wallet.Include(w => w.Balances).SingleAsync(w => w.MemberID == authorID);
  183. Assert.AreEqual(3, likerStats.Exp, "추천을 누른 회원에게 LikeGivenExp 지급");
  184. Assert.AreEqual(7m, authorWallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "작성자에게 LikeReceivedPoint 지급");
  185. // 본인 글 자추천은 받은 쪽 미지급
  186. using var db2 = TestDb.Create();
  187. await RewardService.GrantForLikeAsync(db2, new NullCacheService(), likerID, likerID, postID: 43, default);
  188. await db2.SaveChangesAsync(default);
  189. using var verify2 = TestDb.Create();
  190. var selfWallet = await verify2.Wallet.Include(w => w.Balances).SingleAsync(w => w.MemberID == likerID);
  191. Assert.AreEqual(0m, selfWallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "본인 글 자추천은 받은 쪽 토큰 미지급");
  192. }
  193. [TestMethod]
  194. public async Task GrantForChat_GivesExp_Capped()
  195. {
  196. using var db = TestDb.Create();
  197. await SeedConfigAsync(db, new RewardConfig { ChatDailyExp = 5 });
  198. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  199. // 매 메시지 2 XP, 일일 5 XP 상한 → 2 + 2 + 1(clamp) = 5, 이후 스킵
  200. for (int i = 0; i < 4; i++)
  201. {
  202. using var ctx = TestDb.Create();
  203. await RewardService.GrantForChatAsync(ctx, new NullCacheService(), memberID, chatXp: 2, DateTime.UtcNow.AddSeconds(i), default);
  204. await ctx.SaveChangesAsync(default);
  205. }
  206. using var verify = TestDb.Create();
  207. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  208. Assert.AreEqual(5, stats.Exp, "채팅 XP 는 일일 경험치 상한(5)으로 clamp 되어야 한다");
  209. }
  210. [TestMethod]
  211. public void StoreOrder_CannotSpendToken_CashOnlyPolicy()
  212. {
  213. // 상점 결제는 캐시(PgCharged/Deposit) 전용 — 토큰(Reward/Airdrop)은 사용 불가.
  214. var wallet = Wallet.Create(1);
  215. wallet.CreditReward(Money.KRW(1000), "REWARD"); // 토큰만 보유
  216. Assert.AreEqual(0m, wallet.GetStoreOrderAvailable().Value, "토큰만 있는 지갑의 상점 가용 잔액은 0 이어야 한다");
  217. Assert.ThrowsExactly<InvalidOperationException>(() => wallet.DebitForStoreOrder(Money.KRW(1)), "토큰으로는 상점 결제 차감이 불가해야 한다");
  218. CollectionAssert.AreEqual(
  219. new[] { WalletBalanceType.PgCharged, WalletBalanceType.Deposit },
  220. SpendPolicy.StoreOrderSpendOrder);
  221. }
  222. [TestMethod]
  223. public void StoreOrder_SpendsCash_WhenCashPresent()
  224. {
  225. var wallet = Wallet.Create(1);
  226. wallet.CreditPgCharge(Money.KRW(500));
  227. Assert.AreEqual(500m, wallet.GetStoreOrderAvailable().Value);
  228. var splits = wallet.DebitForStoreOrder(Money.KRW(300));
  229. Assert.AreEqual(1, splits.Count);
  230. Assert.AreEqual(WalletBalanceType.PgCharged, splits[0].Type);
  231. Assert.AreEqual(300m, splits[0].Amount.Value);
  232. Assert.AreEqual(200m, wallet.GetBalance(WalletBalanceType.PgCharged).Value);
  233. }
  234. }