MemberItemTests.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. using Domain.Entities.Chat;
  2. using Domain.Entities.Common.ValueObject;
  3. using Domain.Entities.Forum.Boards;
  4. using Domain.Entities.Forum.Posts;
  5. using Domain.Entities.Members;
  6. using Domain.Entities.Store;
  7. using Domain.Entities.Store.ValueObject;
  8. using Domain.Entities.Wallets;
  9. using Domain.Entities.Wallets.ValueObject;
  10. using Infrastructure.Persistence;
  11. using Microsoft.EntityFrameworkCore;
  12. using UseCommand = Application.Features.Api.Member.Items.Use.Command;
  13. using UseHandler = Application.Features.Api.Member.Items.Use.Handler;
  14. using ListHandler = Application.Features.Api.Member.Items.List.Handler;
  15. using ListQuery = Application.Features.Api.Member.Items.List.Query;
  16. using PlaceCommand = Application.Features.Api.Store.Orders.Place.Command;
  17. using PlaceHandler = Application.Features.Api.Store.Orders.Place.Handler;
  18. namespace Application.Tests;
  19. /// <summary>
  20. /// P3 소모품 상점(계정 귀속 MemberItem) 검증 — 도메인 규칙 + Use/List/Place 핸들러 + 공용 헬퍼.
  21. /// 운영과 동일한 LocalDB(antooza_test) SQL Server 프로바이더로 EF 동작을 그대로 검증한다.
  22. /// </summary>
  23. [TestClass]
  24. public sealed class MemberItemTests
  25. {
  26. // ── 시드 헬퍼 ────────────────────────────────────────────────
  27. /// <summary>소모품 상품(Digital + ItemKind)을 시드하고 Product 를 반환한다.</summary>
  28. private static async Task<Product> SeedItemProductAsync(AppDbContext db, ItemKind kind, int durationDays = 0, int price = 0)
  29. {
  30. var product = Product.Create(
  31. gameID: null,
  32. name: $"소모품-{kind}",
  33. type: ProductType.Digital,
  34. price: price,
  35. discountType: DiscountType.None,
  36. discountValue: 0,
  37. stock: -1,
  38. itemKind: kind,
  39. durationDays: durationDays
  40. );
  41. await db.Product.AddAsync(product);
  42. await db.SaveChangesAsync(default);
  43. return product;
  44. }
  45. /// <summary>회원 소유의 미사용 MemberItem 을 직접 발급하고 반환한다 (구매 경로 우회).</summary>
  46. private static async Task<MemberItem> SeedMemberItemAsync(AppDbContext db, int memberID, ItemKind kind, int durationDays = 0)
  47. {
  48. var product = await SeedItemProductAsync(db, kind, durationDays);
  49. // OrderItemID FK 제약이 없으므로(회수 연동 스냅샷용) 양수 더미로 충분하다.
  50. var item = MemberItem.Create(memberID, product.ID, orderItemID: 999999, kind: kind, durationDays: durationDays);
  51. await db.MemberItem.AddAsync(item);
  52. await db.SaveChangesAsync(default);
  53. return item;
  54. }
  55. /// <summary>본인 소유 활성/미신고/무댓글 게시글 1건을 시드하고 PostID 를 반환한다.</summary>
  56. private static async Task<int> SeedPostAsync(AppDbContext db, int memberID, int reports = 0, int comments = 0, bool isDeleted = false)
  57. {
  58. var group = new BoardGroup { Code = $"g-{Guid.NewGuid():N}"[..20], Name = "테스트 그룹" };
  59. await db.BoardGroup.AddAsync(group);
  60. await db.SaveChangesAsync(default);
  61. var board = new Board { BoardGroupID = group.ID, Code = $"b-{Guid.NewGuid():N}"[..20], Name = "테스트 게시판", IsActive = true };
  62. await db.Board.AddAsync(board);
  63. await db.SaveChangesAsync(default);
  64. var post = new Post
  65. {
  66. BoardID = board.ID,
  67. MemberID = memberID,
  68. Subject = "테스트 글",
  69. Content = "본문",
  70. Reports = reports,
  71. Comments = comments,
  72. IsDeleted = isDeleted
  73. };
  74. await db.Post.AddAsync(post);
  75. await db.SaveChangesAsync(default);
  76. return post.ID;
  77. }
  78. // ── 도메인: MemberItem 라이프사이클 ───────────────────────────
  79. [TestMethod]
  80. public void Create_NewItem_IsAvailable_NotUsedNotRevoked()
  81. {
  82. // 기대 행위: 신규 발급 아이템은 미사용·미회수 → 사용 가능.
  83. var item = MemberItem.Create(memberID: 1, productID: 2, orderItemID: 3, kind: ItemKind.NotePass, durationDays: 0);
  84. Assert.IsTrue(item.IsAvailable, "신규 아이템은 사용 가능해야 한다");
  85. Assert.IsNull(item.UsedAt);
  86. Assert.IsNull(item.RevokedAt);
  87. Assert.AreEqual(ItemKind.NotePass, item.Kind);
  88. }
  89. [TestMethod]
  90. public void MarkUsed_ThenAgain_Throws_AndBlocksRevoke()
  91. {
  92. // 기대 행위: MarkUsed 후 재사용은 예외, 사용한 아이템은 더 이상 회수 불가.
  93. var now = DateTime.UtcNow;
  94. var item = MemberItem.Create(1, 2, 3, ItemKind.ChatBanLift, 0);
  95. item.MarkUsed(now);
  96. Assert.IsFalse(item.IsAvailable, "사용 후에는 사용 불가여야 한다");
  97. Assert.AreEqual(now, item.UsedAt);
  98. Assert.ThrowsException<InvalidOperationException>(() => item.MarkUsed(now), "이미 사용한 아이템 재사용은 예외");
  99. Assert.IsFalse(item.Revoke(now), "사용한 아이템은 회수되지 않아야 한다");
  100. }
  101. [TestMethod]
  102. public void Revoke_UnusedItem_Succeeds_ThenBlocksUse()
  103. {
  104. // 기대 행위: 미사용 아이템은 회수 가능하고, 회수 후에는 사용(MarkUsed)이 예외.
  105. var now = DateTime.UtcNow;
  106. var item = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, 7);
  107. Assert.IsTrue(item.Revoke(now), "미사용 아이템은 회수되어야 한다");
  108. Assert.IsFalse(item.IsAvailable);
  109. Assert.AreEqual(now, item.RevokedAt);
  110. Assert.IsFalse(item.Revoke(now), "이미 회수된 아이템 재회수는 false");
  111. Assert.ThrowsException<InvalidOperationException>(() => item.MarkUsed(now), "회수된 아이템은 사용 예외");
  112. }
  113. [TestMethod]
  114. public void IsBoostActiveAt_OnlyForUsedExpBooster_WithinWindow()
  115. {
  116. // 기대 행위: ExpBooster 는 사용 후 DurationDays 창 이내에서만 활성. 미사용/타종류/만료/회수는 비활성.
  117. var used = DateTime.UtcNow;
  118. var booster = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, durationDays: 3);
  119. Assert.IsFalse(booster.IsBoostActiveAt(used), "미사용 부스터는 비활성");
  120. booster.MarkUsed(used);
  121. Assert.IsTrue(booster.IsBoostActiveAt(used.AddDays(1)), "창 이내는 활성");
  122. Assert.IsFalse(booster.IsBoostActiveAt(used.AddDays(3).AddSeconds(1)), "창 만료 후는 비활성");
  123. var notBooster = MemberItem.Create(1, 2, 3, ItemKind.NotePass, durationDays: 3);
  124. notBooster.MarkUsed(used);
  125. Assert.IsFalse(notBooster.IsBoostActiveAt(used.AddDays(1)), "부스터가 아니면 항상 비활성");
  126. }
  127. // ── Use 핸들러 ───────────────────────────────────────────────
  128. [TestMethod]
  129. public async Task Use_ChatBanLift_LiftsActiveBans_AndMarksUsed()
  130. {
  131. // 기대 행위: 채팅 밴 해제권 사용 시 본인 활성 제재를 해제하고 아이템을 소모 + 사용 로그를 남긴다.
  132. using var db = TestDb.Create();
  133. var memberID = await TestDb.CreateMemberAsync(db);
  134. await db.ChatBan.AddAsync(ChatBan.Create(memberID, null, "전체 제재", null, "admin"));
  135. await db.ChatBan.AddAsync(ChatBan.Create(memberID, "main", "메인 제재", null, "admin"));
  136. await db.SaveChangesAsync(default);
  137. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift);
  138. var handler = new UseHandler(db);
  139. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  140. Assert.IsTrue(result.IsSuccess, "밴 해제권 사용은 성공해야 한다");
  141. Assert.AreEqual(ItemKind.ChatBanLift, result.Value.Kind);
  142. using var verify = TestDb.Create();
  143. var now = DateTime.UtcNow;
  144. Assert.IsFalse(await ChatBanCheckerIsBanned(verify, memberID, "main", now), "제재가 해제되어야 한다");
  145. Assert.IsFalse(await ChatBanCheckerIsBanned(verify, memberID, "stock:005930", now), "전체 제재도 해제되어야 한다");
  146. var used = await verify.MemberItem.SingleAsync(i => i.ID == item.ID);
  147. Assert.IsFalse(used.IsAvailable, "사용 후 소모 상태여야 한다");
  148. Assert.AreEqual(1, await verify.MemberItemUsageLog.CountAsync(l => l.MemberItemID == item.ID), "사용 로그가 1건 남아야 한다");
  149. }
  150. [TestMethod]
  151. public async Task Use_ChatBanLift_NoActiveBan_Fails_AndItemNotConsumed()
  152. {
  153. // 기대 행위: 해제할 활성 제재가 없으면 실패하고 아이템은 소모되지 않는다.
  154. using var db = TestDb.Create();
  155. var memberID = await TestDb.CreateMemberAsync(db);
  156. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift);
  157. var handler = new UseHandler(db);
  158. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  159. Assert.IsFalse(result.IsSuccess, "해제할 제재가 없으면 실패");
  160. Assert.AreEqual("MemberItem.NoActiveBan", result.Error.Code);
  161. using var verify = TestDb.Create();
  162. var stored = await verify.MemberItem.SingleAsync(i => i.ID == item.ID);
  163. Assert.IsTrue(stored.IsAvailable, "실패 시 아이템은 소모되지 않아야 한다");
  164. }
  165. [TestMethod]
  166. public async Task Use_PostReset_DeletesOnlyCleanPosts_KeepsReportedOrCommented()
  167. {
  168. // 기대 행위: 글 초기화권은 신고·댓글 없는 본인 글만 삭제하고, 신고/댓글 있는 글은 남긴다.
  169. using var db = TestDb.Create();
  170. var memberID = await TestDb.CreateMemberAsync(db);
  171. var cleanPostID = await SeedPostAsync(db, memberID);
  172. var reportedPostID = await SeedPostAsync(db, memberID, reports: 2);
  173. var commentedPostID = await SeedPostAsync(db, memberID, comments: 3);
  174. var item = await SeedMemberItemAsync(db, memberID, ItemKind.PostReset);
  175. var handler = new UseHandler(db);
  176. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  177. Assert.IsTrue(result.IsSuccess, "초기화 대상 글이 있으면 성공");
  178. using var verify = TestDb.Create();
  179. Assert.IsTrue((await verify.Post.SingleAsync(p => p.ID == cleanPostID)).IsDeleted, "깨끗한 글은 삭제되어야 한다");
  180. Assert.IsFalse((await verify.Post.SingleAsync(p => p.ID == reportedPostID)).IsDeleted, "신고된 글은 남아야 한다");
  181. Assert.IsFalse((await verify.Post.SingleAsync(p => p.ID == commentedPostID)).IsDeleted, "댓글 달린 글은 남아야 한다");
  182. }
  183. [TestMethod]
  184. public async Task Use_ExpBooster_MarksUsed_AndActivatesBoost()
  185. {
  186. // 기대 행위: 경험치 부스터 사용은 MarkUsed 만으로 활성화되고, 이후 활성 판정이 참이 된다.
  187. using var db = TestDb.Create();
  188. var memberID = await TestDb.CreateMemberAsync(db);
  189. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  190. var handler = new UseHandler(db);
  191. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  192. Assert.IsTrue(result.IsSuccess, "부스터 사용은 성공해야 한다");
  193. using var verify = TestDb.Create();
  194. var used = await verify.MemberItem.SingleAsync(i => i.ID == item.ID);
  195. Assert.IsNotNull(used.UsedAt, "사용 시각이 기록되어야 한다");
  196. Assert.IsTrue(used.IsBoostActiveAt(DateTime.UtcNow), "사용 직후 부스터가 활성이어야 한다");
  197. }
  198. [TestMethod]
  199. public async Task Use_NotePass_Rejected_NotDirectlyUsable()
  200. {
  201. // 기대 행위: 쪽지권 등은 직접 사용 불가 — 해당 기능 이용 시 자동 소모되므로 Use 는 거절한다.
  202. using var db = TestDb.Create();
  203. var memberID = await TestDb.CreateMemberAsync(db);
  204. var item = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass);
  205. var handler = new UseHandler(db);
  206. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  207. Assert.IsFalse(result.IsSuccess);
  208. Assert.AreEqual("MemberItem.NotDirectlyUsable", result.Error.Code);
  209. using var verify = TestDb.Create();
  210. Assert.IsTrue((await verify.MemberItem.SingleAsync(i => i.ID == item.ID)).IsAvailable, "거절 시 소모되지 않아야 한다");
  211. }
  212. [TestMethod]
  213. public async Task Use_NotOwnedItem_ReturnsNotFound()
  214. {
  215. // 기대 행위: 타인 소유 아이템은 소유자 검증에서 NotFound.
  216. using var db = TestDb.Create();
  217. var ownerID = await TestDb.CreateMemberAsync(db);
  218. var otherID = await TestDb.CreateMemberAsync(db);
  219. var item = await SeedMemberItemAsync(db, ownerID, ItemKind.ChatBanLift);
  220. var handler = new UseHandler(db);
  221. var result = await handler.Handle(new UseCommand(otherID, item.ID), default);
  222. Assert.IsFalse(result.IsSuccess);
  223. Assert.AreEqual("MemberItem.NotFound", result.Error.Code);
  224. }
  225. [TestMethod]
  226. public async Task Use_AlreadyUsedItem_ReturnsConflict()
  227. {
  228. // 기대 행위: 이미 사용한 아이템 재사용은 Conflict(Unavailable).
  229. using var db = TestDb.Create();
  230. var memberID = await TestDb.CreateMemberAsync(db);
  231. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  232. var handler = new UseHandler(db);
  233. var first = await handler.Handle(new UseCommand(memberID, item.ID), default);
  234. Assert.IsTrue(first.IsSuccess);
  235. using var db2 = TestDb.Create();
  236. var handler2 = new UseHandler(db2);
  237. var second = await handler2.Handle(new UseCommand(memberID, item.ID), default);
  238. Assert.IsFalse(second.IsSuccess);
  239. Assert.AreEqual("MemberItem.Unavailable", second.Error.Code);
  240. }
  241. // ── MemberItemConsumer 헬퍼 ───────────────────────────────────
  242. [TestMethod]
  243. public async Task Consumer_TryConsume_MarksUsed_AndLogs_WithoutSaving()
  244. {
  245. // 기대 행위: TryConsume 은 사용 가능한 아이템 1개를 소모하고 로그를 추가한다 (SaveChanges 는 호출측 위임).
  246. using var db = TestDb.Create();
  247. var memberID = await TestDb.CreateMemberAsync(db);
  248. var item = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass);
  249. var now = DateTime.UtcNow;
  250. var consumed = await Application.Helpers.MemberItemConsumer.TryConsumeAsync(db, memberID, ItemKind.NotePass, now, "쪽지 초과 발송", default);
  251. Assert.IsTrue(consumed, "사용 가능한 아이템이 있으면 소모되어야 한다");
  252. await db.SaveChangesAsync(default);
  253. using var verify = TestDb.Create();
  254. Assert.IsFalse((await verify.MemberItem.SingleAsync(i => i.ID == item.ID)).IsAvailable, "소모 후 사용 불가여야 한다");
  255. Assert.AreEqual(1, await verify.MemberItemUsageLog.CountAsync(l => l.MemberItemID == item.ID), "사용 로그가 남아야 한다");
  256. }
  257. [TestMethod]
  258. public async Task Consumer_HasAvailable_And_TryConsume_NoneAvailable()
  259. {
  260. // 기대 행위: 보유가 없으면 HasAvailable=false, TryConsume=false.
  261. using var db = TestDb.Create();
  262. var memberID = await TestDb.CreateMemberAsync(db);
  263. Assert.IsFalse(await Application.Helpers.MemberItemConsumer.HasAvailableAsync(db, memberID, ItemKind.NotePass, default), "보유 없음");
  264. var consumed = await Application.Helpers.MemberItemConsumer.TryConsumeAsync(db, memberID, ItemKind.NotePass, DateTime.UtcNow, null, default);
  265. Assert.IsFalse(consumed, "보유가 없으면 소모 실패");
  266. }
  267. // ── ExpBoostService 헬퍼 ──────────────────────────────────────
  268. [TestMethod]
  269. public async Task ExpBoost_Doubles_WhenActiveBoosterExists_ElseUnchanged()
  270. {
  271. // 기대 행위: 활성 부스터 보유 시 ApplyAsync 는 baseExp × 2, 미보유 시 baseExp 그대로.
  272. using var db = TestDb.Create();
  273. var memberID = await TestDb.CreateMemberAsync(db);
  274. var now = DateTime.UtcNow;
  275. // 부스터 없음 → 그대로
  276. Assert.AreEqual(10, await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, now, default), "부스터 없으면 원본 유지");
  277. // 활성 부스터 사용
  278. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  279. item.MarkUsed(now);
  280. await db.SaveChangesAsync(default);
  281. Assert.AreEqual(20, await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, now, default), "활성 부스터면 2배");
  282. }
  283. [TestMethod]
  284. public async Task ExpBoost_ExpiredBooster_DoesNotDouble()
  285. {
  286. // 기대 행위: 창이 만료된 부스터는 2배를 적용하지 않는다.
  287. using var db = TestDb.Create();
  288. var memberID = await TestDb.CreateMemberAsync(db);
  289. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 1);
  290. var usedAt = DateTime.UtcNow.AddDays(-2);
  291. item.MarkUsed(usedAt);
  292. await db.SaveChangesAsync(default);
  293. var applied = await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, DateTime.UtcNow, default);
  294. Assert.AreEqual(10, applied, "만료 부스터는 2배 미적용");
  295. }
  296. // ── Place 핸들러: 소모품 구매 시 MemberItem 발급 ───────────────
  297. [TestMethod]
  298. public async Task Place_ConsumablePurchase_IssuesOneMemberItemPerUnit()
  299. {
  300. // 기대 행위: ItemKind 상품(쿠폰 없음, GameID null)을 구매하면 수량만큼 계정 귀속 MemberItem 이 발급된다.
  301. using var db = TestDb.Create();
  302. var memberID = await TestDb.CreateMemberAsync(db);
  303. var wallet = Wallet.Create(memberID);
  304. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  305. await db.Wallet.AddAsync(wallet);
  306. await db.SaveChangesAsync(default);
  307. var product = await SeedItemProductAsync(db, ItemKind.ExpBooster, durationDays: 7, price: 1000);
  308. var handler = new PlaceHandler(db);
  309. var result = await handler.Handle(
  310. new PlaceCommand(memberID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 3)]),
  311. default);
  312. Assert.IsTrue(result.IsSuccess, $"소모품 구매는 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Code)}");
  313. Assert.IsTrue(result.Value.HasDigital, "소모품은 Digital 로 취급된다");
  314. using var verify = TestDb.Create();
  315. var items = await verify.MemberItem.Where(i => i.MemberID == memberID && i.ProductID == product.ID).ToListAsync();
  316. Assert.AreEqual(3, items.Count, "구매 수량(3)만큼 MemberItem 이 발급되어야 한다");
  317. Assert.IsTrue(items.All(i => i.Kind == ItemKind.ExpBooster && i.DurationDays == 7 && i.IsAvailable),
  318. "발급된 아이템은 종류·기간 스냅샷이 올바르고 사용 가능해야 한다");
  319. }
  320. // ── List 핸들러 ──────────────────────────────────────────────
  321. [TestMethod]
  322. public async Task List_ExcludesUsedByDefault_AndRevokedAlways()
  323. {
  324. // 기대 행위: 기본 조회는 미사용만, IncludeUsed=true 면 사용분 포함. 회수분은 항상 제외.
  325. using var db = TestDb.Create();
  326. var memberID = await TestDb.CreateMemberAsync(db);
  327. var now = DateTime.UtcNow;
  328. var available = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift);
  329. var used = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  330. used.MarkUsed(now);
  331. var revoked = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass);
  332. revoked.Revoke(now);
  333. await db.SaveChangesAsync(default);
  334. var handler = new ListHandler(db);
  335. var defaultList = await handler.Handle(new ListQuery(memberID), default);
  336. Assert.AreEqual(1, defaultList.Total, "기본 조회는 미사용 1건");
  337. Assert.AreEqual(available.ID, defaultList.List[0].ID);
  338. var withUsed = await handler.Handle(new ListQuery(memberID, IncludeUsed: true), default);
  339. Assert.AreEqual(2, withUsed.Total, "IncludeUsed 는 미사용+사용 2건 (회수분 제외)");
  340. Assert.IsFalse(withUsed.List.Any(r => r.ID == revoked.ID), "회수분은 항상 제외");
  341. }
  342. // ── 테스트 로컬 헬퍼 ──────────────────────────────────────────
  343. private static Task<bool> ChatBanCheckerIsBanned(AppDbContext db, int memberID, string roomKey, DateTime now)
  344. => Application.Helpers.ChatBanChecker.IsBannedAsync(db, memberID, roomKey, now, default);
  345. }