using Application.Abstractions.Cache; using Application.Helpers; using Domain.Entities.Common; using Domain.Entities.Common.ValueObject; using Domain.Entities.Members; using Domain.Entities.Wallets; using Domain.Entities.Wallets.Policy; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Tests; [TestClass] public sealed class ActivityTokenGranterTests { // 캐시는 항상 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, int postToken, int commentToken, int dailyCap) { var config = Config.Create(); config.UpdateActivityToken(new ActivityTokenConfig { PostToken = postToken, CommentToken = commentToken, DailyTokenCap = dailyCap }); await db.Config.AddAsync(config); await db.SaveChangesAsync(default); } private static async Task CreateMemberWithWalletAsync(Infrastructure.Persistence.AppDbContext db) { var member = Member.Create($"activity-{Guid.NewGuid():N}@antooza.test"); await db.Member.AddAsync(member); await db.SaveChangesAsync(default); await db.Wallet.AddAsync(Wallet.Create(member.ID)); await db.SaveChangesAsync(default); return member.ID; } [TestMethod] public async Task GrantForPost_CreditsRewardPartition_WithActivityRefID() { using var db = TestDb.Create(); await SeedConfigAsync(db, postToken: 100, commentToken: 50, dailyCap: 0); var memberID = await CreateMemberWithWalletAsync(db); await ActivityTokenGranter.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 777, default); await db.SaveChangesAsync(default); var refID = ActivityTokenGranter.PostRefIDFor(777); 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(100m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 토큰은 Reward 파티션에 적립되어야 한다"); var tx = wallet.Transactions.Single(t => t.RefID == refID); Assert.AreEqual(WalletBalanceType.Reward, tx.BalanceType); Assert.AreEqual(WalletTransactionType.RewardEarned, tx.TxType); Assert.AreEqual(ActivityTokenGranter.PostReason, tx.Reason); } [TestMethod] public async Task GrantForComment_IsIdempotent_NoDoubleCredit() { using var db = TestDb.Create(); await SeedConfigAsync(db, postToken: 0, commentToken: 30, dailyCap: 0); var memberID = await CreateMemberWithWalletAsync(db); await ActivityTokenGranter.GrantForCommentAsync(db, new NullCacheService(), memberID, commentID: 900, default); await db.SaveChangesAsync(default); // 동일 댓글 재호출 (신규 컨텍스트) using var db2 = TestDb.Create(); await ActivityTokenGranter.GrantForCommentAsync(db2, new NullCacheService(), memberID, commentID: 900, default); await db2.SaveChangesAsync(default); var refID = ActivityTokenGranter.CommentRefIDFor(900); 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(30m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "동일 댓글 재호출 시 이중 적립되지 않아야 한다"); Assert.AreEqual(1, wallet.Transactions.Count(t => t.RefID == refID), "활동 토큰 원장은 정확히 1건이어야 한다"); } [TestMethod] public async Task GrantForPost_ZeroConfiguredAmount_GrantsNothing() { using var db = TestDb.Create(); await SeedConfigAsync(db, postToken: 0, commentToken: 50, dailyCap: 0); var memberID = await CreateMemberWithWalletAsync(db); await ActivityTokenGranter.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 1, default); await db.SaveChangesAsync(default); 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.Reward).Amount.Value, "게시글 토큰 0 이면 미지급"); Assert.AreEqual(0, wallet.Transactions.Count, "0 지급 시 원장 미기록"); } [TestMethod] public async Task GrantForPost_DailyCap_ClampsSameDayGrants() { using var db = TestDb.Create(); // 게시글당 100, 일일 상한 250 → 3번째 게시글은 50 으로 clamp, 4번째는 0(스킵) await SeedConfigAsync(db, postToken: 100, commentToken: 0, dailyCap: 250); var memberID = await CreateMemberWithWalletAsync(db); for (int postID = 1; postID <= 4; postID++) { using var ctx = TestDb.Create(); await ActivityTokenGranter.GrantForPostAsync(ctx, new NullCacheService(), memberID, postID, default); await ctx.SaveChangesAsync(default); } 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(250m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "일일 상한(250)을 넘겨 적립되면 안 된다"); // 100 + 100 + 50 = 3건 (4번째는 남은 한도 0 이라 스킵) Assert.AreEqual(3, wallet.Transactions.Count(t => t.TxType == WalletTransactionType.RewardEarned), "상한 도달 후에는 추가 지급되지 않아야 한다"); } [TestMethod] public void StoreOrder_CannotSpendToken_CashOnlyPolicy() { // d4 M0: 상점 결제는 캐시(PgCharged/Deposit) 전용 — 토큰(Reward/Airdrop)은 사용 불가. var wallet = Wallet.Create(1); wallet.CreditReward(Money.KRW(1000), "REWARD"); // 토큰만 보유 Assert.AreEqual(0m, wallet.GetStoreOrderAvailable().Value, "토큰만 있는 지갑의 상점 가용 잔액은 0 이어야 한다"); Assert.ThrowsExactly(() => wallet.DebitForStoreOrder(Money.KRW(1)), "토큰으로는 상점 결제 차감이 불가해야 한다"); // 정책 배열 자체가 캐시 2종만 포함하는지도 확인 CollectionAssert.AreEqual( new[] { WalletBalanceType.PgCharged, WalletBalanceType.Deposit }, SpendPolicy.StoreOrderSpendOrder); } [TestMethod] public void StoreOrder_SpendsCash_WhenCashPresent() { var wallet = Wallet.Create(1); wallet.CreditPgCharge(Money.KRW(500)); Assert.AreEqual(500m, wallet.GetStoreOrderAvailable().Value); var splits = wallet.DebitForStoreOrder(Money.KRW(300)); Assert.AreEqual(1, splits.Count); Assert.AreEqual(WalletBalanceType.PgCharged, splits[0].Type); Assert.AreEqual(300m, splits[0].Amount.Value); Assert.AreEqual(200m, wallet.GetBalance(WalletBalanceType.PgCharged).Value); } }