| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- 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<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
- public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
- public Task SetAsync<T>(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<bool> 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<int> 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<InvalidOperationException>(() => 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);
- }
- }
|