using Application.Abstractions.Cache; using Application.Helpers; using Domain.Entities.Common; using Domain.Entities.Common.ValueObject; using Domain.Entities.Members; using Domain.Entities.Members.ValueObject; using Domain.Entities.Wallets; using Domain.Entities.Wallets.Policy; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Tests; [TestClass] public sealed class RewardServiceTests { // 캐시는 항상 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, RewardConfig reward) { var config = Config.Create(); config.UpdateReward(reward); await db.Config.AddAsync(config); await db.SaveChangesAsync(default); } private static async Task CreateMemberWithWalletAndStatsAsync(Infrastructure.Persistence.AppDbContext db) { var member = Member.Create($"reward-{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 GrantForPost_CreditsExpAndToken_WithRewardRefID() { using var db = TestDb.Create(); await SeedConfigAsync(db, new RewardConfig { PostExp = 10, PostPoint = 100 }); var memberID = await CreateMemberWithWalletAndStatsAsync(db); await RewardService.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 777, contentLength: 50, 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); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(100m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 토큰은 Reward 파티션에 적립"); Assert.AreEqual(10, stats.Exp, "게시글 경험치는 MemberStats.Exp 에 적립"); var tx = wallet.Transactions.Single(t => t.RefID == RewardService.PostRefIDFor(777)); Assert.AreEqual(WalletBalanceType.Reward, tx.BalanceType); Assert.AreEqual(WalletTransactionType.RewardEarned, tx.TxType); var counter = await verify.MemberDailyRewardCounter.SingleAsync(c => c.MemberID == memberID && c.ActionType == RewardActionType.Post); Assert.AreEqual(1, counter.Count); Assert.AreEqual(10, counter.ExpTotal); Assert.AreEqual(100, counter.PointTotal); } [TestMethod] public async Task GrantForComment_IsIdempotent_NoDoubleCredit() { using var db = TestDb.Create(); await SeedConfigAsync(db, new RewardConfig { CommentExp = 5, CommentPoint = 30 }); var memberID = await CreateMemberWithWalletAndStatsAsync(db); await RewardService.GrantForCommentAsync(db, new NullCacheService(), memberID, commentID: 900, contentLength: 20, default); await db.SaveChangesAsync(default); // 동일 댓글 재호출 (신규 컨텍스트) using var db2 = TestDb.Create(); await RewardService.GrantForCommentAsync(db2, new NullCacheService(), memberID, commentID: 900, contentLength: 20, default); await db2.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); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(30m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "동일 댓글 재호출 시 토큰 이중 적립 금지"); Assert.AreEqual(5, stats.Exp, "동일 댓글 재호출 시 경험치 이중 적립 금지"); Assert.AreEqual(1, wallet.Transactions.Count(t => t.RefID == RewardService.CommentRefIDFor(900)), "활동 원장은 정확히 1건"); } [TestMethod] public async Task GrantForPost_ZeroConfiguredAmount_GrantsNothing() { using var db = TestDb.Create(); await SeedConfigAsync(db, new RewardConfig { PostExp = 0, PostPoint = 0, CommentExp = 5 }); var memberID = await CreateMemberWithWalletAndStatsAsync(db); await RewardService.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 1, contentLength: 50, 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); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(0m, wallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "게시글 보상 0 이면 미지급"); Assert.AreEqual(0, stats.Exp, "게시글 보상 0 이면 경험치 미지급"); Assert.AreEqual(0, wallet.Transactions.Count, "0 지급 시 원장 미기록"); Assert.AreEqual(0, await verify.MemberDailyRewardCounter.CountAsync(c => c.MemberID == memberID), "0 지급 시 카운터 미기록"); } [TestMethod] public async Task GrantForPost_BelowMinLength_GrantsNothing() { using var db = TestDb.Create(); await SeedConfigAsync(db, new RewardConfig { PostExp = 10, PostPoint = 100, MinPostLength = 30 }); var memberID = await CreateMemberWithWalletAndStatsAsync(db); await RewardService.GrantForPostAsync(db, new NullCacheService(), memberID, postID: 5, contentLength: 10, default); await db.SaveChangesAsync(default); using var verify = TestDb.Create(); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(0, stats.Exp, "최소 글자수 미만이면 미지급"); } [TestMethod] public async Task GrantForPost_DailyPointCap_ClampsSameDayGrants() { using var db = TestDb.Create(); // 게시글당 토큰 100, 일일 토큰 상한 250 → 3번째 게시글은 50 으로 clamp, 4번째는 0(스킵) await SeedConfigAsync(db, new RewardConfig { PostPoint = 100, PostDailyPoint = 250 }); var memberID = await CreateMemberWithWalletAndStatsAsync(db); for (int postID = 1; postID <= 4; postID++) { using var ctx = TestDb.Create(); await RewardService.GrantForPostAsync(ctx, new NullCacheService(), memberID, postID, contentLength: 50, 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)을 넘겨 적립되면 안 된다"); Assert.AreEqual(3, wallet.Transactions.Count(t => t.TxType == WalletTransactionType.RewardEarned), "상한 도달 후에는 추가 지급되지 않아야 한다"); var counter = await verify.MemberDailyRewardCounter.SingleAsync(c => c.MemberID == memberID && c.ActionType == RewardActionType.Post); Assert.AreEqual(250, counter.PointTotal, "카운터 누적치도 상한과 일치"); } [TestMethod] public async Task GrantForPost_DailyCountCap_SkipsBeyondCount() { using var db = TestDb.Create(); var memberID = await CreateMemberWithWalletAndStatsAsync(db); // 하루 2건까지만 경험치 지급. 공유 DB Config 경합 회피 위해 명시 config 로 직접 지급. var cfg = new RewardConfig { PostExp = 10, PostDailyCount = 2 }; for (int postID = 1; postID <= 3; postID++) { using var ctx = TestDb.Create(); await RewardService.GrantAsync(ctx, memberID, RewardActionType.Post, 10, 0, RewardService.PostRefIDFor(920000 + postID), "게시글 작성 보상", cfg, default); await ctx.SaveChangesAsync(default); } using var verify = TestDb.Create(); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); var counter = await verify.MemberDailyRewardCounter.SingleAsync(c => c.MemberID == memberID && c.ActionType == RewardActionType.Post); Assert.AreEqual(20, stats.Exp, "일일 횟수 상한(2)을 넘긴 3번째 글은 미지급"); Assert.AreEqual(2, counter.Count); } [TestMethod] public async Task GrantForPost_AutoPromotesGrade_WhenExpReachesNextRequired() { using var db = TestDb.Create(); var memberID = await CreateMemberWithWalletAndStatsAsync(db); var cfg = new RewardConfig { PostExp = 100 }; // 활성 등급: 새싹(0), 중수(50), 고수(500) var g1 = MemberGrade.Create("새싹", "Sprout", 1, true); g1.Update("새싹", "Sprout", null, 1, null, null, 0, 0, true); var g2 = MemberGrade.Create("중수", "Mid", 2, true); g2.Update("중수", "Mid", null, 2, null, null, 50, 0, true); var g3 = MemberGrade.Create("고수", "Pro", 3, true); g3.Update("고수", "Pro", null, 3, null, null, 500, 0, true); await db.MemberGrade.AddRangeAsync(g1, g2, g3); await db.SaveChangesAsync(default); // 100 XP → 중수(50) 충족 (고수 500 은 미충족). 공유 DB Config 경합 회피 위해 명시 config 로 직접 지급. await RewardService.GrantAsync(db, memberID, RewardActionType.Post, 100, 0, RewardService.PostRefIDFor(910001), "게시글 작성 보상", cfg, default); await db.SaveChangesAsync(default); using var verify = TestDb.Create(); var member = await verify.Member.SingleAsync(m => m.ID == memberID); Assert.AreEqual(g2.ID, member.MemberGradeID, "100 XP 로 중수(RequiredExp=50) 등급으로 자동 승급되어야 한다"); } [TestMethod] public async Task GrantForLike_GivesExpToLiker_AndTokenToAuthor_ExcludingSelf() { using var db = TestDb.Create(); await SeedConfigAsync(db, new RewardConfig { LikeGivenExp = 3, LikeReceivedPoint = 7 }); var likerID = await CreateMemberWithWalletAndStatsAsync(db); var authorID = await CreateMemberWithWalletAndStatsAsync(db); await RewardService.GrantForLikeAsync(db, new NullCacheService(), likerID, authorID, postID: 42, default); await db.SaveChangesAsync(default); using var verify = TestDb.Create(); var likerStats = await verify.MemberStats.SingleAsync(s => s.MemberID == likerID); var authorWallet = await verify.Wallet.Include(w => w.Balances).SingleAsync(w => w.MemberID == authorID); Assert.AreEqual(3, likerStats.Exp, "추천을 누른 회원에게 LikeGivenExp 지급"); Assert.AreEqual(7m, authorWallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "작성자에게 LikeReceivedPoint 지급"); // 본인 글 자추천은 받은 쪽 미지급 using var db2 = TestDb.Create(); await RewardService.GrantForLikeAsync(db2, new NullCacheService(), likerID, likerID, postID: 43, default); await db2.SaveChangesAsync(default); using var verify2 = TestDb.Create(); var selfWallet = await verify2.Wallet.Include(w => w.Balances).SingleAsync(w => w.MemberID == likerID); Assert.AreEqual(0m, selfWallet.Balances.Single(b => b.Type == WalletBalanceType.Reward).Amount.Value, "본인 글 자추천은 받은 쪽 토큰 미지급"); } [TestMethod] public async Task GrantForChat_GivesExp_Capped() { using var db = TestDb.Create(); await SeedConfigAsync(db, new RewardConfig { ChatDailyExp = 5 }); var memberID = await CreateMemberWithWalletAndStatsAsync(db); // 매 메시지 2 XP, 일일 5 XP 상한 → 2 + 2 + 1(clamp) = 5, 이후 스킵 for (int i = 0; i < 4; i++) { using var ctx = TestDb.Create(); await RewardService.GrantForChatAsync(ctx, new NullCacheService(), memberID, chatXp: 2, DateTime.UtcNow.AddSeconds(i), default); await ctx.SaveChangesAsync(default); } using var verify = TestDb.Create(); var stats = await verify.MemberStats.SingleAsync(s => s.MemberID == memberID); Assert.AreEqual(5, stats.Exp, "채팅 XP 는 일일 경험치 상한(5)으로 clamp 되어야 한다"); } [TestMethod] public void StoreOrder_CannotSpendToken_CashOnlyPolicy() { // 상점 결제는 캐시(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)), "토큰으로는 상점 결제 차감이 불가해야 한다"); 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); } }