using Application.Abstractions.Cache; using Application.Helpers; using Domain.Entities.Common; using Domain.Entities.Members; using Domain.Entities.Wallets; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Tests; [TestClass] public sealed class SignupRewardGranterTests { // 캐시는 항상 miss → DB Config 폴백 경로를 검증한다. private sealed class NullCacheService : ICacheService { public Task GetAsync(string key, CancellationToken ct = default) => Task.FromResult(default); public Task SetAsync(string key, T value, CancellationToken ct = default) => Task.CompletedTask; public Task SetAsync(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask; public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask; public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask; public Task TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true); } private static async Task SeedConfigAsync(Infrastructure.Persistence.AppDbContext db, bool enabled, int coin, int cash, int exp) { var config = Config.Create(); config.Update( config.Basic, config.Images, config.Meta, config.Company, config.Account, config.EmailTemplate, config.External, config.Crypto, config.Attendance, new SignupRewardConfig { Enabled = enabled, CoinAmount = coin, CashAmount = cash, ExpAmount = exp } ); await db.Config.AddAsync(config); await db.SaveChangesAsync(default); } private static async Task CreateMemberWithWalletAndStatsAsync(Infrastructure.Persistence.AppDbContext db) { var member = Member.Create($"signup-{Guid.NewGuid():N}@antooza.test"); await db.Member.AddAsync(member); await db.SaveChangesAsync(default); await db.MemberStats.AddAsync(MemberStats.Create(member.ID)); await db.Wallet.AddAsync(Wallet.Create(member.ID)); await db.SaveChangesAsync(default); return member.ID; } [TestMethod] public async Task Grant_CreditsCoinCashExp_OnEnabledConfig() { using var db = TestDb.Create(); await SeedConfigAsync(db, enabled: true, coin: 500, cash: 300, exp: 20); var memberID = await CreateMemberWithWalletAndStatsAsync(db); await SignupRewardGranter.GrantAsync(db, new NullCacheService(), memberID, default); await db.SaveChangesAsync(default); var refID = SignupRewardGranter.RefIDFor(memberID); using var verify = TestDb.Create(); var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID); Assert.AreEqual(500m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Airdrop).Amount.Value, "코인은 Airdrop 파티션에 적립되어야 한다"); Assert.AreEqual(300m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Adjusted).Amount.Value, "캐시는 Adjusted 파티션에 적립되어야 한다"); var coinTx = wallet.Transactions.Single(t => t.BalanceType == WalletBalanceType.Airdrop && t.RefID == refID); var cashTx = wallet.Transactions.Single(t => t.BalanceType == WalletBalanceType.Adjusted && t.RefID == refID); Assert.AreEqual(WalletTransactionType.RewardEarned, coinTx.TxType, "멱등 판정 일원화를 위해 TxType 은 RewardEarned 여야 한다"); Assert.AreEqual(WalletTransactionType.RewardEarned, cashTx.TxType); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(20L, stats.Exp, "XP 는 MemberStats.Exp 에 반영되어야 한다"); var expLog = await verify.MemberExpLog.Where(l => l.MemberID == memberID).ToListAsync(); Assert.AreEqual(1, expLog.Count, "가입 XP 로그가 1건 남아야 한다"); Assert.AreEqual(20, expLog[0].Amount); } [TestMethod] public async Task Grant_IsIdempotent_NoDoubleCredit() { using var db = TestDb.Create(); await SeedConfigAsync(db, enabled: true, coin: 500, cash: 0, exp: 10); var memberID = await CreateMemberWithWalletAndStatsAsync(db); // 1차 지급 await SignupRewardGranter.GrantAsync(db, new NullCacheService(), memberID, default); await db.SaveChangesAsync(default); // 2차 재호출 (신규 컨텍스트 — 실제 재가입/재로그인 시뮬레이션) using var db2 = TestDb.Create(); await SignupRewardGranter.GrantAsync(db2, new NullCacheService(), memberID, default); await db2.SaveChangesAsync(default); var refID = SignupRewardGranter.RefIDFor(memberID); using var verify = TestDb.Create(); var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID); Assert.AreEqual(500m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Airdrop).Amount.Value, "재호출해도 코인은 이중 적립되지 않아야 한다"); Assert.AreEqual(1, wallet.Transactions.Count(t => t.RefID == refID), "가입 보상 원장은 정확히 1건이어야 한다"); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(10L, stats.Exp, "XP 도 이중 지급되지 않아야 한다"); var expLogCount = await verify.MemberExpLog.CountAsync(l => l.MemberID == memberID); Assert.AreEqual(1, expLogCount, "XP 로그도 1건만 남아야 한다"); } [TestMethod] public async Task Grant_DisabledConfig_GrantsNothing() { using var db = TestDb.Create(); await SeedConfigAsync(db, enabled: false, coin: 500, cash: 300, exp: 20); var memberID = await CreateMemberWithWalletAndStatsAsync(db); await SignupRewardGranter.GrantAsync(db, new NullCacheService(), memberID, default); await db.SaveChangesAsync(default); var refID = SignupRewardGranter.RefIDFor(memberID); using var verify = TestDb.Create(); var wallet = await verify.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().SingleAsync(w => w.MemberID == memberID); Assert.AreEqual(0m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Airdrop).Amount.Value, "비활성 시 코인 미지급"); Assert.AreEqual(0m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Adjusted).Amount.Value, "비활성 시 캐시 미지급"); Assert.AreEqual(0, wallet.Transactions.Count(t => t.RefID == refID), "비활성 시 원장 미기록"); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(0L, stats.Exp, "비활성 시 XP 미지급"); } [TestMethod] public async Task Grant_AllZeroAmounts_GrantsNothing_EvenIfEnabled() { using var db = TestDb.Create(); await SeedConfigAsync(db, enabled: true, coin: 0, cash: 0, exp: 0); var memberID = await CreateMemberWithWalletAndStatsAsync(db); await SignupRewardGranter.GrantAsync(db, new NullCacheService(), memberID, default); await db.SaveChangesAsync(default); var refID = SignupRewardGranter.RefIDFor(memberID); using var verify = TestDb.Create(); var txCount = await verify.WalletTransaction.CountAsync(t => t.RefID == refID); Assert.AreEqual(0, txCount, "활성이라도 전액 0 이면 아무것도 지급하지 않는다"); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(0L, stats.Exp); } }