| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344 |
- 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;
- /// <summary>
- /// D3 M4 응원(Cheer) 통합 테스트. 발신자 차감(캐시→토큰), 작성자 순액 적립(Reward 파티션, 출금 불가),
- /// 수수료 소각, 본인 글 금지, 최소금액, 일일 발신 캡을 검증한다.
- /// 공유 antooza_test DB 이므로 회원/게시글 PK 는 시드로 생성돼 고유하며, refID 는 핸들러가 GUID 로 생성한다.
- /// </summary>
- [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<int> 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());
- /// <summary>cheer 필드를 지정한 Config 를 최신 row 로 시드한다 (핸들러는 최신 Config 를 읽음).</summary>
- 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);
- }
- /// <summary>회원 + 지갑(+ 캐시 충전) + MemberStats 를 생성하고 회원 ID 반환.</summary>
- private static async Task<int> 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;
- }
- /// <summary>authorID 소유의 활성 게시글 1건 시드 후 PostID 반환.</summary>
- private static async Task<int> 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<decimal> 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");
- }
- }
- }
|