using Application.Abstractions.Notification; using Application.Features.Api.Forum.PostCheer.Send; using Domain.Entities.Common; using Domain.Entities.Common.ValueObject; using Domain.Entities.Forum.Boards; using Domain.Entities.Forum.Posts; using Domain.Entities.Members; using Domain.Entities.Members.ValueObject; using Domain.Entities.Notifications.ValueObject; using Domain.Entities.Wallets; using Domain.Entities.Wallets.ValueObject; using Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Application.Tests; /// /// D3 M4 응원(Cheer) 통합 테스트. 발신자 차감(캐시→토큰), 작성자 순액 적립(Reward 파티션, 출금 불가), /// 수수료 소각, 본인 글 금지, 최소금액, 일일 발신 캡을 검증한다. /// 공유 antooza_test DB 이므로 회원/게시글 PK 는 시드로 생성돼 고유하며, refID 는 핸들러가 GUID 로 생성한다. /// [TestClass] public sealed class PostCheerTests { // 알림은 부수효과 — 검증 대상 아님. no-op 구현. private sealed class NullNotificationService : INotificationService { public Task SendAsync(int memberID, NotificationType type, string title, string message, string? actionUrl = null, string? relatedType = null, int? relatedID = null, string? imageUrl = null, CancellationToken ct = default) => Task.CompletedTask; public Task SendToManyAsync(IEnumerable memberIDs, NotificationType type, string title, string message, string? actionUrl = null, string? relatedType = null, int? relatedID = null, string? imageUrl = null, CancellationToken ct = default) => Task.CompletedTask; } private static Handler NewHandler(AppDbContext db) => new(db, new NullNotificationService()); /// cheer 필드를 지정한 Config 를 최신 row 로 시드한다 (핸들러는 최신 Config 를 읽음). private static async Task SeedCheerConfigAsync(AppDbContext db, int feePercent, int minAmount, int dailyMax) { var config = Config.Create(); config.UpdateReward(new RewardConfig { CheerFeePercent = feePercent, CheerMinAmount = minAmount, CheerDailyMax = dailyMax }); await db.Config.AddAsync(config); await db.SaveChangesAsync(default); } /// 회원 + 지갑(+ 캐시 충전) + MemberStats 를 생성하고 회원 ID 반환. private static async Task CreateMemberWithCashAsync(AppDbContext db, int cash = 0) { var member = Member.Create($"cheer-{Guid.NewGuid():N}@antooza.test"); await db.Member.AddAsync(member); await db.SaveChangesAsync(default); await db.MemberStats.AddAsync(MemberStats.Create(member.ID)); var wallet = Wallet.Create(member.ID); if (cash > 0) { wallet.CreditPgCharge(Money.KRW(cash)); } await db.Wallet.AddAsync(wallet); await db.SaveChangesAsync(default); return member.ID; } /// authorID 소유의 활성 게시글 1건 시드 후 PostID 반환. private static async Task SeedPostAsync(AppDbContext db, int authorID) { var group = new BoardGroup { Code = $"g-{Guid.NewGuid():N}"[..20], Name = "테스트 그룹" }; await db.BoardGroup.AddAsync(group); await db.SaveChangesAsync(default); var board = new Board { BoardGroupID = group.ID, Code = $"b-{Guid.NewGuid():N}"[..20], Name = "테스트 게시판", IsActive = true }; await db.Board.AddAsync(board); await db.SaveChangesAsync(default); var post = new Post { BoardID = board.ID, MemberID = authorID, Subject = "분석글", Content = "본문" }; await db.Post.AddAsync(post); await db.SaveChangesAsync(default); return post.ID; } private static async Task BalanceAsync(AppDbContext db, int memberID, WalletBalanceType type) { var wallet = await db.Wallet.AsNoTracking().Include(w => w.Balances).SingleAsync(w => w.MemberID == memberID); return wallet.Balances.Single(b => b.Type == type).Amount.Value; } [TestMethod] public async Task SendCheer_DebitsSender_CreditsAuthorNet_FeeRetained() { using (var db = TestDb.Create()) { await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 0); } int fromID, authorID, postID; using (var db = TestDb.Create()) { fromID = await CreateMemberWithCashAsync(db, cash: 1000); authorID = await CreateMemberWithCashAsync(db, cash: 0); postID = await SeedPostAsync(db, authorID); } int cheerID; using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 500, Message: "좋은 분석 감사합니다"), default); Assert.IsTrue(result.IsSuccess, "정상 응원은 성공해야 한다"); cheerID = result.Value; } using (var verify = TestDb.Create()) { // 발신자: 캐시(PgCharged) 500 차감 → 1000-500 = 500 남음 Assert.AreEqual(500m, await BalanceAsync(verify, fromID, WalletBalanceType.PgCharged), "발신자는 총액(500)만큼 차감"); // 수신자: 순액(500 - fee 100 = 400) Reward 적립 Assert.AreEqual(400m, await BalanceAsync(verify, authorID, WalletBalanceType.Reward), "작성자는 순액(400)을 Reward 로 수령"); // 작성자에게 캐시(PgCharged) 로는 적립되지 않음 Assert.AreEqual(0m, await BalanceAsync(verify, authorID, WalletBalanceType.PgCharged), "작성자 캐시 파티션은 변동 없음"); // 수수료(100)는 어디에도 적립되지 않음 = 소각. From 500 차감 vs To 400 적립 → 100 소멸. var cheer = await verify.PostCheer.AsNoTracking().SingleAsync(c => c.ID == cheerID); Assert.AreEqual(500, cheer.Amount); Assert.AreEqual(100, cheer.FeeAmount, "수수료 = floor(500 × 20/100) = 100"); Assert.AreEqual(400, cheer.NetAmount); Assert.AreEqual(fromID, cheer.FromMemberID); Assert.AreEqual(authorID, cheer.ToMemberID); // 원장 타입 검증 var toWallet = await verify.Wallet.AsNoTracking().Include(w => w.Transactions).SingleAsync(w => w.MemberID == authorID); var creditTx = toWallet.Transactions.Single(t => t.TxType == WalletTransactionType.CheerReceived); Assert.AreEqual(WalletBalanceType.Reward, creditTx.BalanceType, "수신은 Reward 파티션"); Assert.AreEqual(400m, creditTx.Amount.Value); var fromWallet = await verify.Wallet.AsNoTracking().Include(w => w.Transactions).SingleAsync(w => w.MemberID == fromID); Assert.IsTrue(fromWallet.Transactions.Any(t => t.TxType == WalletTransactionType.CheerSent), "발신 원장 CheerSent 기록"); } } [TestMethod] public async Task SendCheer_ReceivedIsRewardPartition_NonWithdrawable() { using (var db = TestDb.Create()) { await SeedCheerConfigAsync(db, feePercent: 10, minAmount: 100, dailyMax: 0); } int fromID, authorID, postID; using (var db = TestDb.Create()) { fromID = await CreateMemberWithCashAsync(db, cash: 2000); authorID = await CreateMemberWithCashAsync(db, cash: 0); postID = await SeedPostAsync(db, authorID); } using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 1000, Message: null), default); Assert.IsTrue(result.IsSuccess); } using (var verify = TestDb.Create()) { // net = 1000 - floor(1000*10/100)=100 → 900, Reward 파티션에만. Assert.AreEqual(900m, await BalanceAsync(verify, authorID, WalletBalanceType.Reward), "수령분은 Reward(출금 불가) 파티션"); Assert.AreEqual(0m, await BalanceAsync(verify, authorID, WalletBalanceType.Deposit), "예치금(캐시) 파티션 아님"); Assert.AreEqual(0m, await BalanceAsync(verify, authorID, WalletBalanceType.Donation), "후원 파티션(비활성) 아님"); } } [TestMethod] public async Task SendCheer_SelfCheer_Blocked() { using (var db = TestDb.Create()) { await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 0); } int selfID, postID; using (var db = TestDb.Create()) { selfID = await CreateMemberWithCashAsync(db, cash: 1000); postID = await SeedPostAsync(db, selfID); } using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle(new Command(selfID, postID, Amount: 500, Message: null), default); Assert.IsTrue(result.IsFailure, "본인 글 응원은 실패해야 한다"); Assert.AreEqual("Cheer.SelfNotAllowed", result.Error.Code); } using (var verify = TestDb.Create()) { Assert.AreEqual(1000m, await BalanceAsync(verify, selfID, WalletBalanceType.PgCharged), "차단 시 잔액 변동 없음"); Assert.AreEqual(0, await verify.PostCheer.CountAsync(c => c.PostID == postID), "차단 시 PostCheer 미생성"); } } [TestMethod] public async Task SendCheer_BelowMin_Blocked() { using (var db = TestDb.Create()) { await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 0); } int fromID, authorID, postID; using (var db = TestDb.Create()) { fromID = await CreateMemberWithCashAsync(db, cash: 1000); authorID = await CreateMemberWithCashAsync(db, cash: 0); postID = await SeedPostAsync(db, authorID); } using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 50, Message: null), default); Assert.IsTrue(result.IsFailure, "최소 금액 미만은 실패해야 한다"); Assert.AreEqual("Cheer.BelowMin", result.Error.Code); } using (var verify = TestDb.Create()) { Assert.AreEqual(1000m, await BalanceAsync(verify, fromID, WalletBalanceType.PgCharged), "차단 시 잔액 변동 없음"); } } [TestMethod] public async Task SendCheer_DailyMaxCap_BlocksExceeding() { // 일일 발신 총액 상한 600. 첫 400 성공, 두 번째 300(누적 700>600) 차단. using (var db = TestDb.Create()) { await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 600); } int fromID, authorID, postID; using (var db = TestDb.Create()) { fromID = await CreateMemberWithCashAsync(db, cash: 5000); authorID = await CreateMemberWithCashAsync(db, cash: 0); postID = await SeedPostAsync(db, authorID); } using (var db = TestDb.Create()) { var r1 = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 400, Message: null), default); Assert.IsTrue(r1.IsSuccess, "첫 응원(400)은 상한(600) 이내로 성공"); } using (var db = TestDb.Create()) { var r2 = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 300, Message: null), default); Assert.IsTrue(r2.IsFailure, "누적 700 은 상한(600) 초과로 차단"); Assert.AreEqual("Cheer.DailyCapExceeded", r2.Error.Code); } using (var db = TestDb.Create()) { // 200 이면 누적 600 = 상한 도달, 성공해야 한다 (경계값). var r3 = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 200, Message: null), default); Assert.IsTrue(r3.IsSuccess, "누적 600 = 상한 경계는 허용"); } using (var verify = TestDb.Create()) { Assert.AreEqual(2, await verify.PostCheer.CountAsync(c => c.FromMemberID == fromID), "성공한 응원은 2건(400 + 200)"); } } [TestMethod] public async Task SendCheer_InsufficientBalance_Blocked() { using (var db = TestDb.Create()) { await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 0); } int fromID, authorID, postID; using (var db = TestDb.Create()) { fromID = await CreateMemberWithCashAsync(db, cash: 100); authorID = await CreateMemberWithCashAsync(db, cash: 0); postID = await SeedPostAsync(db, authorID); } using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 500, Message: null), default); Assert.IsTrue(result.IsFailure, "잔액 부족은 실패해야 한다"); Assert.AreEqual("Cheer.InsufficientBalance", result.Error.Code); } using (var verify = TestDb.Create()) { Assert.AreEqual(100m, await BalanceAsync(verify, fromID, WalletBalanceType.PgCharged), "차단 시 잔액 변동 없음"); } } [TestMethod] public async Task SendCheer_SpendsCashBeforeToken() { // 캐시 300 + 토큰(Reward) 500 보유. 600 응원 → 캐시 300 전액 + 토큰 300 차감(CheerSpendOrder). using (var db = TestDb.Create()) { await SeedCheerConfigAsync(db, feePercent: 0, minAmount: 100, dailyMax: 0); } int fromID, authorID, postID; using (var db = TestDb.Create()) { fromID = await CreateMemberWithCashAsync(db, cash: 300); authorID = await CreateMemberWithCashAsync(db, cash: 0); postID = await SeedPostAsync(db, authorID); var fromWallet = await db.Wallet.Include(w => w.Balances).SingleAsync(w => w.MemberID == fromID); fromWallet.CreditReward(Money.KRW(500)); await db.SaveChangesAsync(default); } using (var db = TestDb.Create()) { var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 600, Message: null), default); Assert.IsTrue(result.IsSuccess); } using (var verify = TestDb.Create()) { Assert.AreEqual(0m, await BalanceAsync(verify, fromID, WalletBalanceType.PgCharged), "캐시 먼저 전액 소진"); Assert.AreEqual(200m, await BalanceAsync(verify, fromID, WalletBalanceType.Reward), "토큰은 나머지 300 차감 → 500-300=200"); } } }