ActivityTokenGranterTests.cs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  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.Wallets;
  7. using Domain.Entities.Wallets.Policy;
  8. using Domain.Entities.Wallets.ValueObject;
  9. using Microsoft.EntityFrameworkCore;
  10. namespace Application.Tests;
  11. [TestClass]
  12. public sealed class ActivityTokenGranterTests
  13. {
  14. // 캐시는 항상 miss → DB Config 폴백 경로를 검증한다.
  15. private sealed class NullCacheService : ICacheService
  16. {
  17. public Task<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
  18. public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
  19. public Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask;
  20. public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask;
  21. public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask;
  22. public Task<bool> TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
  23. }
  24. private static async Task SeedConfigAsync(Infrastructure.Persistence.AppDbContext db, int postToken, int commentToken, int dailyCap)
  25. {
  26. var config = Config.Create();
  27. config.UpdateActivityToken(new ActivityTokenConfig { PostToken = postToken, CommentToken = commentToken, DailyTokenCap = dailyCap });
  28. await db.Config.AddAsync(config);
  29. await db.SaveChangesAsync(default);
  30. }
  31. private static async Task<int> CreateMemberWithWalletAsync(Infrastructure.Persistence.AppDbContext db)
  32. {
  33. var member = Member.Create($"activity-{Guid.NewGuid():N}@antooza.test");
  34. await db.Member.AddAsync(member);
  35. await db.SaveChangesAsync(default);
  36. await db.Wallet.AddAsync(Wallet.Create(member.ID));
  37. await db.SaveChangesAsync(default);
  38. return member.ID;
  39. }
  40. [TestMethod]
  41. public async Task GrantForPost_CreditsRewardPartition_WithActivityRefID()
  42. {
  43. using var db = TestDb.Create();
  44. await SeedConfigAsync(db, postToken: 100, commentToken: 50, dailyCap: 0);
  45. var memberID = await CreateMemberWithWalletAsync(db);
  46. await ActivityTokenGranter.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 777, default);
  47. await db.SaveChangesAsync(default);
  48. var refID = ActivityTokenGranter.PostRefIDFor(777);
  49. using var verify = TestDb.Create();
  50. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  51. Assert.AreEqual(100m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 토큰은 Reward 파티션에 적립되어야 한다");
  52. var tx = wallet.Transactions.Single(t => t.RefID == refID);
  53. Assert.AreEqual(WalletBalanceType.Reward, tx.BalanceType);
  54. Assert.AreEqual(WalletTransactionType.RewardEarned, tx.TxType);
  55. Assert.AreEqual(ActivityTokenGranter.PostReason, tx.Reason);
  56. }
  57. [TestMethod]
  58. public async Task GrantForComment_IsIdempotent_NoDoubleCredit()
  59. {
  60. using var db = TestDb.Create();
  61. await SeedConfigAsync(db, postToken: 0, commentToken: 30, dailyCap: 0);
  62. var memberID = await CreateMemberWithWalletAsync(db);
  63. await ActivityTokenGranter.GrantForCommentAsync(db, new NullCacheService(), memberID, commentID: 900, default);
  64. await db.SaveChangesAsync(default);
  65. // 동일 댓글 재호출 (신규 컨텍스트)
  66. using var db2 = TestDb.Create();
  67. await ActivityTokenGranter.GrantForCommentAsync(db2, new NullCacheService(), memberID, commentID: 900, default);
  68. await db2.SaveChangesAsync(default);
  69. var refID = ActivityTokenGranter.CommentRefIDFor(900);
  70. using var verify = TestDb.Create();
  71. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  72. Assert.AreEqual(30m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "동일 댓글 재호출 시 이중 적립되지 않아야 한다");
  73. Assert.AreEqual(1, wallet.Transactions.Count(t => t.RefID == refID), "활동 토큰 원장은 정확히 1건이어야 한다");
  74. }
  75. [TestMethod]
  76. public async Task GrantForPost_ZeroConfiguredAmount_GrantsNothing()
  77. {
  78. using var db = TestDb.Create();
  79. await SeedConfigAsync(db, postToken: 0, commentToken: 50, dailyCap: 0);
  80. var memberID = await CreateMemberWithWalletAsync(db);
  81. await ActivityTokenGranter.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 1, default);
  82. await db.SaveChangesAsync(default);
  83. using var verify = TestDb.Create();
  84. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  85. Assert.AreEqual(0m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 토큰 0 이면 미지급");
  86. Assert.AreEqual(0, wallet.Transactions.Count, "0 지급 시 원장 미기록");
  87. }
  88. [TestMethod]
  89. public async Task GrantForPost_DailyCap_ClampsSameDayGrants()
  90. {
  91. using var db = TestDb.Create();
  92. // 게시글당 100, 일일 상한 250 → 3번째 게시글은 50 으로 clamp, 4번째는 0(스킵)
  93. await SeedConfigAsync(db, postToken: 100, commentToken: 0, dailyCap: 250);
  94. var memberID = await CreateMemberWithWalletAsync(db);
  95. for (int postID = 1; postID <= 4; postID++)
  96. {
  97. using var ctx = TestDb.Create();
  98. await ActivityTokenGranter.GrantForPostAsync(ctx, new NullCacheService(), memberID, postID, default);
  99. await ctx.SaveChangesAsync(default);
  100. }
  101. using var verify = TestDb.Create();
  102. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  103. Assert.AreEqual(250m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "일일 상한(250)을 넘겨 적립되면 안 된다");
  104. // 100 + 100 + 50 = 3건 (4번째는 남은 한도 0 이라 스킵)
  105. Assert.AreEqual(3, wallet.Transactions.Count(t => t.TxType == WalletTransactionType.RewardEarned), "상한 도달 후에는 추가 지급되지 않아야 한다");
  106. }
  107. [TestMethod]
  108. public void StoreOrder_CannotSpendToken_CashOnlyPolicy()
  109. {
  110. // d4 M0: 상점 결제는 캐시(PgCharged/Deposit) 전용 — 토큰(Reward/Airdrop)은 사용 불가.
  111. var wallet = Wallet.Create(1);
  112. wallet.CreditReward(Money.KRW(1000), "REWARD"); // 토큰만 보유
  113. Assert.AreEqual(0m, wallet.GetStoreOrderAvailable().Value, "토큰만 있는 지갑의 상점 가용 잔액은 0 이어야 한다");
  114. Assert.ThrowsExactly<InvalidOperationException>(() => wallet.DebitForStoreOrder(Money.KRW(1)), "토큰으로는 상점 결제 차감이 불가해야 한다");
  115. // 정책 배열 자체가 캐시 2종만 포함하는지도 확인
  116. CollectionAssert.AreEqual(
  117. new[] { WalletBalanceType.PgCharged, WalletBalanceType.Deposit },
  118. SpendPolicy.StoreOrderSpendOrder);
  119. }
  120. [TestMethod]
  121. public void StoreOrder_SpendsCash_WhenCashPresent()
  122. {
  123. var wallet = Wallet.Create(1);
  124. wallet.CreditPgCharge(Money.KRW(500));
  125. Assert.AreEqual(500m, wallet.GetStoreOrderAvailable().Value);
  126. var splits = wallet.DebitForStoreOrder(Money.KRW(300));
  127. Assert.AreEqual(1, splits.Count);
  128. Assert.AreEqual(WalletBalanceType.PgCharged, splits[0].Type);
  129. Assert.AreEqual(300m, splits[0].Amount.Value);
  130. Assert.AreEqual(200m, wallet.GetBalance(WalletBalanceType.PgCharged).Value);
  131. }
  132. }