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");
}
}
}