PostCheerTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. using Application.Abstractions.Notification;
  2. using Application.Features.Api.Forum.PostCheer.Send;
  3. using Domain.Entities.Common;
  4. using Domain.Entities.Common.ValueObject;
  5. using Domain.Entities.Forum.Boards;
  6. using Domain.Entities.Forum.Posts;
  7. using Domain.Entities.Members;
  8. using Domain.Entities.Members.ValueObject;
  9. using Domain.Entities.Notifications.ValueObject;
  10. using Domain.Entities.Wallets;
  11. using Domain.Entities.Wallets.ValueObject;
  12. using Infrastructure.Persistence;
  13. using Microsoft.EntityFrameworkCore;
  14. namespace Application.Tests;
  15. /// <summary>
  16. /// D3 M4 응원(Cheer) 통합 테스트. 발신자 차감(캐시→토큰), 작성자 순액 적립(Reward 파티션, 출금 불가),
  17. /// 수수료 소각, 본인 글 금지, 최소금액, 일일 발신 캡을 검증한다.
  18. /// 공유 antooza_test DB 이므로 회원/게시글 PK 는 시드로 생성돼 고유하며, refID 는 핸들러가 GUID 로 생성한다.
  19. /// </summary>
  20. [TestClass]
  21. public sealed class PostCheerTests
  22. {
  23. // 알림은 부수효과 — 검증 대상 아님. no-op 구현.
  24. private sealed class NullNotificationService : INotificationService
  25. {
  26. public Task SendAsync(int memberID, NotificationType type, string title, string message,
  27. string? actionUrl = null, string? relatedType = null, int? relatedID = null, string? imageUrl = null,
  28. CancellationToken ct = default) => Task.CompletedTask;
  29. public Task SendToManyAsync(IEnumerable<int> memberIDs, NotificationType type, string title, string message,
  30. string? actionUrl = null, string? relatedType = null, int? relatedID = null, string? imageUrl = null,
  31. CancellationToken ct = default) => Task.CompletedTask;
  32. }
  33. private static Handler NewHandler(AppDbContext db) => new(db, new NullNotificationService());
  34. /// <summary>cheer 필드를 지정한 Config 를 최신 row 로 시드한다 (핸들러는 최신 Config 를 읽음).</summary>
  35. private static async Task SeedCheerConfigAsync(AppDbContext db, int feePercent, int minAmount, int dailyMax)
  36. {
  37. var config = Config.Create();
  38. config.UpdateReward(new RewardConfig
  39. {
  40. CheerFeePercent = feePercent,
  41. CheerMinAmount = minAmount,
  42. CheerDailyMax = dailyMax
  43. });
  44. await db.Config.AddAsync(config);
  45. await db.SaveChangesAsync(default);
  46. }
  47. /// <summary>회원 + 지갑(+ 캐시 충전) + MemberStats 를 생성하고 회원 ID 반환.</summary>
  48. private static async Task<int> CreateMemberWithCashAsync(AppDbContext db, int cash = 0)
  49. {
  50. var member = Member.Create($"cheer-{Guid.NewGuid():N}@antooza.test");
  51. await db.Member.AddAsync(member);
  52. await db.SaveChangesAsync(default);
  53. await db.MemberStats.AddAsync(MemberStats.Create(member.ID));
  54. var wallet = Wallet.Create(member.ID);
  55. if (cash > 0)
  56. {
  57. wallet.CreditPgCharge(Money.KRW(cash));
  58. }
  59. await db.Wallet.AddAsync(wallet);
  60. await db.SaveChangesAsync(default);
  61. return member.ID;
  62. }
  63. /// <summary>authorID 소유의 활성 게시글 1건 시드 후 PostID 반환.</summary>
  64. private static async Task<int> SeedPostAsync(AppDbContext db, int authorID)
  65. {
  66. var group = new BoardGroup { Code = $"g-{Guid.NewGuid():N}"[..20], Name = "테스트 그룹" };
  67. await db.BoardGroup.AddAsync(group);
  68. await db.SaveChangesAsync(default);
  69. var board = new Board { BoardGroupID = group.ID, Code = $"b-{Guid.NewGuid():N}"[..20], Name = "테스트 게시판", IsActive = true };
  70. await db.Board.AddAsync(board);
  71. await db.SaveChangesAsync(default);
  72. var post = new Post { BoardID = board.ID, MemberID = authorID, Subject = "분석글", Content = "본문" };
  73. await db.Post.AddAsync(post);
  74. await db.SaveChangesAsync(default);
  75. return post.ID;
  76. }
  77. private static async Task<decimal> BalanceAsync(AppDbContext db, int memberID, WalletBalanceType type)
  78. {
  79. var wallet = await db.Wallet.AsNoTracking().Include(w => w.Balances).SingleAsync(w => w.MemberID == memberID);
  80. return wallet.Balances.Single(b => b.Type == type).Amount.Value;
  81. }
  82. [TestMethod]
  83. public async Task SendCheer_DebitsSender_CreditsAuthorNet_FeeRetained()
  84. {
  85. using (var db = TestDb.Create())
  86. {
  87. await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 0);
  88. }
  89. int fromID, authorID, postID;
  90. using (var db = TestDb.Create())
  91. {
  92. fromID = await CreateMemberWithCashAsync(db, cash: 1000);
  93. authorID = await CreateMemberWithCashAsync(db, cash: 0);
  94. postID = await SeedPostAsync(db, authorID);
  95. }
  96. int cheerID;
  97. using (var db = TestDb.Create())
  98. {
  99. var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 500, Message: "좋은 분석 감사합니다"), default);
  100. Assert.IsTrue(result.IsSuccess, "정상 응원은 성공해야 한다");
  101. cheerID = result.Value;
  102. }
  103. using (var verify = TestDb.Create())
  104. {
  105. // 발신자: 캐시(PgCharged) 500 차감 → 1000-500 = 500 남음
  106. Assert.AreEqual(500m, await BalanceAsync(verify, fromID, WalletBalanceType.PgCharged), "발신자는 총액(500)만큼 차감");
  107. // 수신자: 순액(500 - fee 100 = 400) Reward 적립
  108. Assert.AreEqual(400m, await BalanceAsync(verify, authorID, WalletBalanceType.Reward), "작성자는 순액(400)을 Reward 로 수령");
  109. // 작성자에게 캐시(PgCharged) 로는 적립되지 않음
  110. Assert.AreEqual(0m, await BalanceAsync(verify, authorID, WalletBalanceType.PgCharged), "작성자 캐시 파티션은 변동 없음");
  111. // 수수료(100)는 어디에도 적립되지 않음 = 소각. From 500 차감 vs To 400 적립 → 100 소멸.
  112. var cheer = await verify.PostCheer.AsNoTracking().SingleAsync(c => c.ID == cheerID);
  113. Assert.AreEqual(500, cheer.Amount);
  114. Assert.AreEqual(100, cheer.FeeAmount, "수수료 = floor(500 × 20/100) = 100");
  115. Assert.AreEqual(400, cheer.NetAmount);
  116. Assert.AreEqual(fromID, cheer.FromMemberID);
  117. Assert.AreEqual(authorID, cheer.ToMemberID);
  118. // 원장 타입 검증
  119. var toWallet = await verify.Wallet.AsNoTracking().Include(w => w.Transactions).SingleAsync(w => w.MemberID == authorID);
  120. var creditTx = toWallet.Transactions.Single(t => t.TxType == WalletTransactionType.CheerReceived);
  121. Assert.AreEqual(WalletBalanceType.Reward, creditTx.BalanceType, "수신은 Reward 파티션");
  122. Assert.AreEqual(400m, creditTx.Amount.Value);
  123. var fromWallet = await verify.Wallet.AsNoTracking().Include(w => w.Transactions).SingleAsync(w => w.MemberID == fromID);
  124. Assert.IsTrue(fromWallet.Transactions.Any(t => t.TxType == WalletTransactionType.CheerSent), "발신 원장 CheerSent 기록");
  125. }
  126. }
  127. [TestMethod]
  128. public async Task SendCheer_ReceivedIsRewardPartition_NonWithdrawable()
  129. {
  130. using (var db = TestDb.Create())
  131. {
  132. await SeedCheerConfigAsync(db, feePercent: 10, minAmount: 100, dailyMax: 0);
  133. }
  134. int fromID, authorID, postID;
  135. using (var db = TestDb.Create())
  136. {
  137. fromID = await CreateMemberWithCashAsync(db, cash: 2000);
  138. authorID = await CreateMemberWithCashAsync(db, cash: 0);
  139. postID = await SeedPostAsync(db, authorID);
  140. }
  141. using (var db = TestDb.Create())
  142. {
  143. var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 1000, Message: null), default);
  144. Assert.IsTrue(result.IsSuccess);
  145. }
  146. using (var verify = TestDb.Create())
  147. {
  148. // net = 1000 - floor(1000*10/100)=100 → 900, Reward 파티션에만.
  149. Assert.AreEqual(900m, await BalanceAsync(verify, authorID, WalletBalanceType.Reward), "수령분은 Reward(출금 불가) 파티션");
  150. Assert.AreEqual(0m, await BalanceAsync(verify, authorID, WalletBalanceType.Deposit), "예치금(캐시) 파티션 아님");
  151. Assert.AreEqual(0m, await BalanceAsync(verify, authorID, WalletBalanceType.Donation), "후원 파티션(비활성) 아님");
  152. }
  153. }
  154. [TestMethod]
  155. public async Task SendCheer_SelfCheer_Blocked()
  156. {
  157. using (var db = TestDb.Create())
  158. {
  159. await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 0);
  160. }
  161. int selfID, postID;
  162. using (var db = TestDb.Create())
  163. {
  164. selfID = await CreateMemberWithCashAsync(db, cash: 1000);
  165. postID = await SeedPostAsync(db, selfID);
  166. }
  167. using (var db = TestDb.Create())
  168. {
  169. var result = await NewHandler(db).Handle(new Command(selfID, postID, Amount: 500, Message: null), default);
  170. Assert.IsTrue(result.IsFailure, "본인 글 응원은 실패해야 한다");
  171. Assert.AreEqual("Cheer.SelfNotAllowed", result.Error.Code);
  172. }
  173. using (var verify = TestDb.Create())
  174. {
  175. Assert.AreEqual(1000m, await BalanceAsync(verify, selfID, WalletBalanceType.PgCharged), "차단 시 잔액 변동 없음");
  176. Assert.AreEqual(0, await verify.PostCheer.CountAsync(c => c.PostID == postID), "차단 시 PostCheer 미생성");
  177. }
  178. }
  179. [TestMethod]
  180. public async Task SendCheer_BelowMin_Blocked()
  181. {
  182. using (var db = TestDb.Create())
  183. {
  184. await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 0);
  185. }
  186. int fromID, authorID, postID;
  187. using (var db = TestDb.Create())
  188. {
  189. fromID = await CreateMemberWithCashAsync(db, cash: 1000);
  190. authorID = await CreateMemberWithCashAsync(db, cash: 0);
  191. postID = await SeedPostAsync(db, authorID);
  192. }
  193. using (var db = TestDb.Create())
  194. {
  195. var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 50, Message: null), default);
  196. Assert.IsTrue(result.IsFailure, "최소 금액 미만은 실패해야 한다");
  197. Assert.AreEqual("Cheer.BelowMin", result.Error.Code);
  198. }
  199. using (var verify = TestDb.Create())
  200. {
  201. Assert.AreEqual(1000m, await BalanceAsync(verify, fromID, WalletBalanceType.PgCharged), "차단 시 잔액 변동 없음");
  202. }
  203. }
  204. [TestMethod]
  205. public async Task SendCheer_DailyMaxCap_BlocksExceeding()
  206. {
  207. // 일일 발신 총액 상한 600. 첫 400 성공, 두 번째 300(누적 700>600) 차단.
  208. using (var db = TestDb.Create())
  209. {
  210. await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 600);
  211. }
  212. int fromID, authorID, postID;
  213. using (var db = TestDb.Create())
  214. {
  215. fromID = await CreateMemberWithCashAsync(db, cash: 5000);
  216. authorID = await CreateMemberWithCashAsync(db, cash: 0);
  217. postID = await SeedPostAsync(db, authorID);
  218. }
  219. using (var db = TestDb.Create())
  220. {
  221. var r1 = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 400, Message: null), default);
  222. Assert.IsTrue(r1.IsSuccess, "첫 응원(400)은 상한(600) 이내로 성공");
  223. }
  224. using (var db = TestDb.Create())
  225. {
  226. var r2 = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 300, Message: null), default);
  227. Assert.IsTrue(r2.IsFailure, "누적 700 은 상한(600) 초과로 차단");
  228. Assert.AreEqual("Cheer.DailyCapExceeded", r2.Error.Code);
  229. }
  230. using (var db = TestDb.Create())
  231. {
  232. // 200 이면 누적 600 = 상한 도달, 성공해야 한다 (경계값).
  233. var r3 = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 200, Message: null), default);
  234. Assert.IsTrue(r3.IsSuccess, "누적 600 = 상한 경계는 허용");
  235. }
  236. using (var verify = TestDb.Create())
  237. {
  238. Assert.AreEqual(2, await verify.PostCheer.CountAsync(c => c.FromMemberID == fromID), "성공한 응원은 2건(400 + 200)");
  239. }
  240. }
  241. [TestMethod]
  242. public async Task SendCheer_InsufficientBalance_Blocked()
  243. {
  244. using (var db = TestDb.Create())
  245. {
  246. await SeedCheerConfigAsync(db, feePercent: 20, minAmount: 100, dailyMax: 0);
  247. }
  248. int fromID, authorID, postID;
  249. using (var db = TestDb.Create())
  250. {
  251. fromID = await CreateMemberWithCashAsync(db, cash: 100);
  252. authorID = await CreateMemberWithCashAsync(db, cash: 0);
  253. postID = await SeedPostAsync(db, authorID);
  254. }
  255. using (var db = TestDb.Create())
  256. {
  257. var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 500, Message: null), default);
  258. Assert.IsTrue(result.IsFailure, "잔액 부족은 실패해야 한다");
  259. Assert.AreEqual("Cheer.InsufficientBalance", result.Error.Code);
  260. }
  261. using (var verify = TestDb.Create())
  262. {
  263. Assert.AreEqual(100m, await BalanceAsync(verify, fromID, WalletBalanceType.PgCharged), "차단 시 잔액 변동 없음");
  264. }
  265. }
  266. [TestMethod]
  267. public async Task SendCheer_SpendsCashBeforeToken()
  268. {
  269. // 캐시 300 + 토큰(Reward) 500 보유. 600 응원 → 캐시 300 전액 + 토큰 300 차감(CheerSpendOrder).
  270. using (var db = TestDb.Create())
  271. {
  272. await SeedCheerConfigAsync(db, feePercent: 0, minAmount: 100, dailyMax: 0);
  273. }
  274. int fromID, authorID, postID;
  275. using (var db = TestDb.Create())
  276. {
  277. fromID = await CreateMemberWithCashAsync(db, cash: 300);
  278. authorID = await CreateMemberWithCashAsync(db, cash: 0);
  279. postID = await SeedPostAsync(db, authorID);
  280. var fromWallet = await db.Wallet.Include(w => w.Balances).SingleAsync(w => w.MemberID == fromID);
  281. fromWallet.CreditReward(Money.KRW(500));
  282. await db.SaveChangesAsync(default);
  283. }
  284. using (var db = TestDb.Create())
  285. {
  286. var result = await NewHandler(db).Handle(new Command(fromID, postID, Amount: 600, Message: null), default);
  287. Assert.IsTrue(result.IsSuccess);
  288. }
  289. using (var verify = TestDb.Create())
  290. {
  291. Assert.AreEqual(0m, await BalanceAsync(verify, fromID, WalletBalanceType.PgCharged), "캐시 먼저 전액 소진");
  292. Assert.AreEqual(200m, await BalanceAsync(verify, fromID, WalletBalanceType.Reward), "토큰은 나머지 300 차감 → 500-300=200");
  293. }
  294. }
  295. }