SignupRewardGranterTests.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. using Application.Abstractions.Cache;
  2. using Application.Helpers;
  3. using Domain.Entities.Common;
  4. using Domain.Entities.Members;
  5. using Domain.Entities.Wallets;
  6. using Domain.Entities.Wallets.ValueObject;
  7. using Microsoft.EntityFrameworkCore;
  8. namespace Application.Tests;
  9. [TestClass]
  10. public sealed class SignupRewardGranterTests
  11. {
  12. // 캐시는 항상 miss → DB Config 폴백 경로를 검증한다.
  13. private sealed class NullCacheService : ICacheService
  14. {
  15. public Task<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
  16. public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
  17. public Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask;
  18. public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask;
  19. public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask;
  20. public Task<bool> TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
  21. }
  22. private static async Task SeedConfigAsync(Infrastructure.Persistence.AppDbContext db, bool enabled, int coin, int cash, int exp)
  23. {
  24. var config = Config.Create();
  25. config.Update(
  26. config.Basic,
  27. config.Images,
  28. config.Meta,
  29. config.Company,
  30. config.Account,
  31. config.EmailTemplate,
  32. config.External,
  33. config.Crypto,
  34. config.Attendance,
  35. new SignupRewardConfig { Enabled = enabled, CoinAmount = coin, CashAmount = cash, ExpAmount = exp }
  36. );
  37. await db.Config.AddAsync(config);
  38. await db.SaveChangesAsync(default);
  39. }
  40. private static async Task<int> CreateMemberWithWalletAndStatsAsync(Infrastructure.Persistence.AppDbContext db)
  41. {
  42. var member = Member.Create($"signup-{Guid.NewGuid():N}@antooza.test");
  43. await db.Member.AddAsync(member);
  44. await db.SaveChangesAsync(default);
  45. await db.MemberStats.AddAsync(MemberStats.Create(member.ID));
  46. await db.Wallet.AddAsync(Wallet.Create(member.ID));
  47. await db.SaveChangesAsync(default);
  48. return member.ID;
  49. }
  50. [TestMethod]
  51. public async Task Grant_CreditsCoinCashExp_OnEnabledConfig()
  52. {
  53. using var db = TestDb.Create();
  54. await SeedConfigAsync(db, enabled: true, coin: 500, cash: 300, exp: 20);
  55. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  56. await SignupRewardGranter.GrantAsync(db, new NullCacheService(), memberID, default);
  57. await db.SaveChangesAsync(default);
  58. var refID = SignupRewardGranter.RefIDFor(memberID);
  59. using var verify = TestDb.Create();
  60. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  61. Assert.AreEqual(500m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Airdrop).Amount.Value, "코인은 Airdrop 파티션에 적립되어야 한다");
  62. Assert.AreEqual(300m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Adjusted).Amount.Value, "캐시는 Adjusted 파티션에 적립되어야 한다");
  63. var coinTx = wallet.Transactions.Single(t => t.BalanceType == WalletBalanceType.Airdrop && t.RefID == refID);
  64. var cashTx = wallet.Transactions.Single(t => t.BalanceType == WalletBalanceType.Adjusted && t.RefID == refID);
  65. Assert.AreEqual(WalletTransactionType.RewardEarned, coinTx.TxType, "멱등 판정 일원화를 위해 TxType 은 RewardEarned 여야 한다");
  66. Assert.AreEqual(WalletTransactionType.RewardEarned, cashTx.TxType);
  67. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  68. Assert.AreEqual(20L, stats.Exp, "XP 는 MemberStats.Exp 에 반영되어야 한다");
  69. var expLog = await verify.MemberExpLog.Where(l => l.MemberID == memberID).ToListAsync();
  70. Assert.AreEqual(1, expLog.Count, "가입 XP 로그가 1건 남아야 한다");
  71. Assert.AreEqual(20, expLog[0].Amount);
  72. }
  73. [TestMethod]
  74. public async Task Grant_IsIdempotent_NoDoubleCredit()
  75. {
  76. using var db = TestDb.Create();
  77. await SeedConfigAsync(db, enabled: true, coin: 500, cash: 0, exp: 10);
  78. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  79. // 1차 지급
  80. await SignupRewardGranter.GrantAsync(db, new NullCacheService(), memberID, default);
  81. await db.SaveChangesAsync(default);
  82. // 2차 재호출 (신규 컨텍스트 — 실제 재가입/재로그인 시뮬레이션)
  83. using var db2 = TestDb.Create();
  84. await SignupRewardGranter.GrantAsync(db2, new NullCacheService(), memberID, default);
  85. await db2.SaveChangesAsync(default);
  86. var refID = SignupRewardGranter.RefIDFor(memberID);
  87. using var verify = TestDb.Create();
  88. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  89. Assert.AreEqual(500m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Airdrop).Amount.Value, "재호출해도 코인은 이중 적립되지 않아야 한다");
  90. Assert.AreEqual(1, wallet.Transactions.Count(t => t.RefID == refID), "가입 보상 원장은 정확히 1건이어야 한다");
  91. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  92. Assert.AreEqual(10L, stats.Exp, "XP 도 이중 지급되지 않아야 한다");
  93. var expLogCount = await verify.MemberExpLog.CountAsync(l => l.MemberID == memberID);
  94. Assert.AreEqual(1, expLogCount, "XP 로그도 1건만 남아야 한다");
  95. }
  96. [TestMethod]
  97. public async Task Grant_DisabledConfig_GrantsNothing()
  98. {
  99. using var db = TestDb.Create();
  100. await SeedConfigAsync(db, enabled: false, coin: 500, cash: 300, exp: 20);
  101. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  102. await SignupRewardGranter.GrantAsync(db, new NullCacheService(), memberID, default);
  103. await db.SaveChangesAsync(default);
  104. var refID = SignupRewardGranter.RefIDFor(memberID);
  105. using var verify = TestDb.Create();
  106. var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID);
  107. Assert.AreEqual(0m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Airdrop).Amount.Value, "비활성 시 코인 미지급");
  108. Assert.AreEqual(0m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Adjusted).Amount.Value, "비활성 시 캐시 미지급");
  109. Assert.AreEqual(0, wallet.Transactions.Count(t => t.RefID == refID), "비활성 시 원장 미기록");
  110. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  111. Assert.AreEqual(0L, stats.Exp, "비활성 시 XP 미지급");
  112. }
  113. [TestMethod]
  114. public async Task Grant_AllZeroAmounts_GrantsNothing_EvenIfEnabled()
  115. {
  116. using var db = TestDb.Create();
  117. await SeedConfigAsync(db, enabled: true, coin: 0, cash: 0, exp: 0);
  118. var memberID = await CreateMemberWithWalletAndStatsAsync(db);
  119. await SignupRewardGranter.GrantAsync(db, new NullCacheService(), memberID, default);
  120. await db.SaveChangesAsync(default);
  121. var refID = SignupRewardGranter.RefIDFor(memberID);
  122. using var verify = TestDb.Create();
  123. var txCount = await verify.WalletTransaction.CountAsync(t => t.RefID == refID);
  124. Assert.AreEqual(0, txCount, "활성이라도 전액 0 이면 아무것도 지급하지 않는다");
  125. var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID);
  126. Assert.AreEqual(0L, stats.Exp);
  127. }
  128. }