MemberItemTests.cs 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  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.Members.ValueObject;
  7. using Domain.Entities.Store;
  8. using Domain.Entities.Store.ValueObject;
  9. using Domain.Entities.Wallets;
  10. using Domain.Entities.Wallets.ValueObject;
  11. using Infrastructure.Persistence;
  12. using Microsoft.EntityFrameworkCore;
  13. using UseCommand = Application.Features.Api.Member.Items.Use.Command;
  14. using UseHandler = Application.Features.Api.Member.Items.Use.Handler;
  15. using ListHandler = Application.Features.Api.Member.Items.List.Handler;
  16. using ListQuery = Application.Features.Api.Member.Items.List.Query;
  17. using PlaceCommand = Application.Features.Api.Store.Orders.Place.Command;
  18. using PlaceHandler = Application.Features.Api.Store.Orders.Place.Handler;
  19. using EquipCommand = Application.Features.Api.Member.Items.Equip.Command;
  20. using EquipHandler = Application.Features.Api.Member.Items.Equip.Handler;
  21. using UnequipCommand = Application.Features.Api.Member.Items.Unequip.Command;
  22. using UnequipHandler = Application.Features.Api.Member.Items.Unequip.Handler;
  23. using EquippedQuery = Application.Features.Api.Member.Items.Equipped.Query;
  24. using EquippedHandler = Application.Features.Api.Member.Items.Equipped.Handler;
  25. namespace Application.Tests;
  26. /// <summary>
  27. /// P3 소모품 상점(계정 귀속 MemberItem) 검증 — 도메인 규칙 + Use/List/Place 핸들러 + 공용 헬퍼.
  28. /// 운영과 동일한 LocalDB(antooza_test) SQL Server 프로바이더로 EF 동작을 그대로 검증한다.
  29. /// </summary>
  30. [TestClass]
  31. public sealed class MemberItemTests
  32. {
  33. // ── 시드 헬퍼 ────────────────────────────────────────────────
  34. /// <summary>아이템 상품(Digital + ItemKind)을 시드하고 Product 를 반환한다.</summary>
  35. private static async Task<Product> SeedItemProductAsync(AppDbContext db, ItemKind kind, int durationDays = 0, int price = 0, bool isGiftable = false, string? effectPayload = null)
  36. {
  37. var product = Product.Create(
  38. gameID: null,
  39. name: $"아이템-{kind}",
  40. type: ProductType.Digital,
  41. price: price,
  42. discountType: DiscountType.None,
  43. discountValue: 0,
  44. stock: -1,
  45. itemKind: kind,
  46. durationDays: durationDays,
  47. effectPayload: effectPayload,
  48. isGiftable: isGiftable
  49. );
  50. await db.Product.AddAsync(product);
  51. await db.SaveChangesAsync(default);
  52. return product;
  53. }
  54. /// <summary>회원 소유의 미사용 MemberItem 을 직접 발급하고 반환한다 (구매 경로 우회).</summary>
  55. private static async Task<MemberItem> SeedMemberItemAsync(AppDbContext db, int memberID, ItemKind kind, int durationDays = 0)
  56. {
  57. var product = await SeedItemProductAsync(db, kind, durationDays);
  58. // OrderItemID FK 제약이 없으므로(회수 연동 스냅샷용) 양수 더미로 충분하다.
  59. var item = MemberItem.Create(memberID, product.ID, orderItemID: 999999, kind: kind, durationDays: durationDays);
  60. await db.MemberItem.AddAsync(item);
  61. await db.SaveChangesAsync(default);
  62. return item;
  63. }
  64. /// <summary>본인 소유 활성/미신고/무댓글 게시글 1건을 시드하고 PostID 를 반환한다.</summary>
  65. private static async Task<int> SeedPostAsync(AppDbContext db, int memberID, int reports = 0, int comments = 0, bool isDeleted = false)
  66. {
  67. var group = new BoardGroup { Code = $"g-{Guid.NewGuid():N}"[..20], Name = "테스트 그룹" };
  68. await db.BoardGroup.AddAsync(group);
  69. await db.SaveChangesAsync(default);
  70. var board = new Board { BoardGroupID = group.ID, Code = $"b-{Guid.NewGuid():N}"[..20], Name = "테스트 게시판", IsActive = true };
  71. await db.Board.AddAsync(board);
  72. await db.SaveChangesAsync(default);
  73. var post = new Post
  74. {
  75. BoardID = board.ID,
  76. MemberID = memberID,
  77. Subject = "테스트 글",
  78. Content = "본문",
  79. Reports = reports,
  80. Comments = comments,
  81. IsDeleted = isDeleted
  82. };
  83. await db.Post.AddAsync(post);
  84. await db.SaveChangesAsync(default);
  85. return post.ID;
  86. }
  87. // ── 도메인: MemberItem 라이프사이클 ───────────────────────────
  88. [TestMethod]
  89. public void Create_NewItem_IsAvailable_NotUsedNotRevoked()
  90. {
  91. // 기대 행위: 신규 발급 아이템은 미사용·미회수 → 사용 가능.
  92. var item = MemberItem.Create(memberID: 1, productID: 2, orderItemID: 3, kind: ItemKind.NotePass, durationDays: 0);
  93. Assert.IsTrue(item.IsAvailable, "신규 아이템은 사용 가능해야 한다");
  94. Assert.IsNull(item.UsedAt);
  95. Assert.IsNull(item.RevokedAt);
  96. Assert.AreEqual(ItemKind.NotePass, item.Kind);
  97. }
  98. [TestMethod]
  99. public void MarkUsed_ThenAgain_Throws_AndBlocksRevoke()
  100. {
  101. // 기대 행위: MarkUsed 후 재사용은 예외, 사용한 아이템은 더 이상 회수 불가.
  102. var now = DateTime.UtcNow;
  103. var item = MemberItem.Create(1, 2, 3, ItemKind.ChatBanLift, 0);
  104. item.MarkUsed(now);
  105. Assert.IsFalse(item.IsAvailable, "사용 후에는 사용 불가여야 한다");
  106. Assert.AreEqual(now, item.UsedAt);
  107. Assert.ThrowsException<InvalidOperationException>(() => item.MarkUsed(now), "이미 사용한 아이템 재사용은 예외");
  108. Assert.IsFalse(item.Revoke(now), "사용한 아이템은 회수되지 않아야 한다");
  109. }
  110. [TestMethod]
  111. public void Revoke_UnusedItem_Succeeds_ThenBlocksUse()
  112. {
  113. // 기대 행위: 미사용 아이템은 회수 가능하고, 회수 후에는 사용(MarkUsed)이 예외.
  114. var now = DateTime.UtcNow;
  115. var item = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, 7);
  116. Assert.IsTrue(item.Revoke(now), "미사용 아이템은 회수되어야 한다");
  117. Assert.IsFalse(item.IsAvailable);
  118. Assert.AreEqual(now, item.RevokedAt);
  119. Assert.IsFalse(item.Revoke(now), "이미 회수된 아이템 재회수는 false");
  120. Assert.ThrowsException<InvalidOperationException>(() => item.MarkUsed(now), "회수된 아이템은 사용 예외");
  121. }
  122. [TestMethod]
  123. public void IsBoostActiveAt_OnlyForUsedExpBooster_WithinWindow()
  124. {
  125. // 기대 행위: ExpBooster 는 사용 후 DurationDays 창 이내에서만 활성. 미사용/타종류/만료/회수는 비활성.
  126. var used = DateTime.UtcNow;
  127. var booster = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, durationDays: 3);
  128. Assert.IsFalse(booster.IsBoostActiveAt(used), "미사용 부스터는 비활성");
  129. booster.MarkUsed(used);
  130. Assert.IsTrue(booster.IsBoostActiveAt(used.AddDays(1)), "창 이내는 활성");
  131. Assert.IsFalse(booster.IsBoostActiveAt(used.AddDays(3).AddSeconds(1)), "창 만료 후는 비활성");
  132. var notBooster = MemberItem.Create(1, 2, 3, ItemKind.NotePass, durationDays: 3);
  133. notBooster.MarkUsed(used);
  134. Assert.IsFalse(notBooster.IsBoostActiveAt(used.AddDays(1)), "부스터가 아니면 항상 비활성");
  135. }
  136. // ── Use 핸들러 ───────────────────────────────────────────────
  137. [TestMethod]
  138. public async Task Use_ChatBanLift_LiftsActiveBans_AndMarksUsed()
  139. {
  140. // 기대 행위: 채팅 밴 해제권 사용 시 본인 활성 제재를 해제하고 아이템을 소모 + 사용 로그를 남긴다.
  141. using var db = TestDb.Create();
  142. var memberID = await TestDb.CreateMemberAsync(db);
  143. await db.ChatBan.AddAsync(ChatBan.Create(memberID, null, "전체 제재", null, "admin"));
  144. await db.ChatBan.AddAsync(ChatBan.Create(memberID, "main", "메인 제재", null, "admin"));
  145. await db.SaveChangesAsync(default);
  146. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift);
  147. var handler = new UseHandler(db);
  148. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  149. Assert.IsTrue(result.IsSuccess, "밴 해제권 사용은 성공해야 한다");
  150. Assert.AreEqual(ItemKind.ChatBanLift, result.Value.Kind);
  151. using var verify = TestDb.Create();
  152. var now = DateTime.UtcNow;
  153. Assert.IsFalse(await ChatBanCheckerIsBanned(verify, memberID, "main", now), "제재가 해제되어야 한다");
  154. Assert.IsFalse(await ChatBanCheckerIsBanned(verify, memberID, "stock:005930", now), "전체 제재도 해제되어야 한다");
  155. var used = await verify.MemberItem.SingleAsync(i => i.ID == item.ID);
  156. Assert.IsFalse(used.IsAvailable, "사용 후 소모 상태여야 한다");
  157. Assert.AreEqual(1, await verify.MemberItemUsageLog.CountAsync(l => l.MemberItemID == item.ID), "사용 로그가 1건 남아야 한다");
  158. }
  159. [TestMethod]
  160. public async Task Use_ChatBanLift_NoActiveBan_Fails_AndItemNotConsumed()
  161. {
  162. // 기대 행위: 해제할 활성 제재가 없으면 실패하고 아이템은 소모되지 않는다.
  163. using var db = TestDb.Create();
  164. var memberID = await TestDb.CreateMemberAsync(db);
  165. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift);
  166. var handler = new UseHandler(db);
  167. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  168. Assert.IsFalse(result.IsSuccess, "해제할 제재가 없으면 실패");
  169. Assert.AreEqual("MemberItem.NoActiveBan", result.Error.Code);
  170. using var verify = TestDb.Create();
  171. var stored = await verify.MemberItem.SingleAsync(i => i.ID == item.ID);
  172. Assert.IsTrue(stored.IsAvailable, "실패 시 아이템은 소모되지 않아야 한다");
  173. }
  174. [TestMethod]
  175. public async Task Use_PostReset_DeletesOnlyCleanPosts_KeepsReportedOrCommented()
  176. {
  177. // 기대 행위: 글 초기화권은 신고·댓글 없는 본인 글만 삭제하고, 신고/댓글 있는 글은 남긴다.
  178. using var db = TestDb.Create();
  179. var memberID = await TestDb.CreateMemberAsync(db);
  180. var cleanPostID = await SeedPostAsync(db, memberID);
  181. var reportedPostID = await SeedPostAsync(db, memberID, reports: 2);
  182. var commentedPostID = await SeedPostAsync(db, memberID, comments: 3);
  183. var item = await SeedMemberItemAsync(db, memberID, ItemKind.PostReset);
  184. var handler = new UseHandler(db);
  185. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  186. Assert.IsTrue(result.IsSuccess, "초기화 대상 글이 있으면 성공");
  187. using var verify = TestDb.Create();
  188. Assert.IsTrue((await verify.Post.SingleAsync(p => p.ID == cleanPostID)).IsDeleted, "깨끗한 글은 삭제되어야 한다");
  189. Assert.IsFalse((await verify.Post.SingleAsync(p => p.ID == reportedPostID)).IsDeleted, "신고된 글은 남아야 한다");
  190. Assert.IsFalse((await verify.Post.SingleAsync(p => p.ID == commentedPostID)).IsDeleted, "댓글 달린 글은 남아야 한다");
  191. }
  192. [TestMethod]
  193. public async Task Use_ExpBooster_MarksUsed_AndActivatesBoost()
  194. {
  195. // 기대 행위: 경험치 부스터 사용은 MarkUsed 만으로 활성화되고, 이후 활성 판정이 참이 된다.
  196. using var db = TestDb.Create();
  197. var memberID = await TestDb.CreateMemberAsync(db);
  198. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  199. var handler = new UseHandler(db);
  200. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  201. Assert.IsTrue(result.IsSuccess, "부스터 사용은 성공해야 한다");
  202. using var verify = TestDb.Create();
  203. var used = await verify.MemberItem.SingleAsync(i => i.ID == item.ID);
  204. Assert.IsNotNull(used.UsedAt, "사용 시각이 기록되어야 한다");
  205. Assert.IsTrue(used.IsBoostActiveAt(DateTime.UtcNow), "사용 직후 부스터가 활성이어야 한다");
  206. }
  207. [TestMethod]
  208. public async Task Use_NotePass_Rejected_NotDirectlyUsable()
  209. {
  210. // 기대 행위: 쪽지권 등은 직접 사용 불가 — 해당 기능 이용 시 자동 소모되므로 Use 는 거절한다.
  211. using var db = TestDb.Create();
  212. var memberID = await TestDb.CreateMemberAsync(db);
  213. var item = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass);
  214. var handler = new UseHandler(db);
  215. var result = await handler.Handle(new UseCommand(memberID, item.ID), default);
  216. Assert.IsFalse(result.IsSuccess);
  217. Assert.AreEqual("MemberItem.NotDirectlyUsable", result.Error.Code);
  218. using var verify = TestDb.Create();
  219. Assert.IsTrue((await verify.MemberItem.SingleAsync(i => i.ID == item.ID)).IsAvailable, "거절 시 소모되지 않아야 한다");
  220. }
  221. [TestMethod]
  222. public async Task Use_NotOwnedItem_ReturnsNotFound()
  223. {
  224. // 기대 행위: 타인 소유 아이템은 소유자 검증에서 NotFound.
  225. using var db = TestDb.Create();
  226. var ownerID = await TestDb.CreateMemberAsync(db);
  227. var otherID = await TestDb.CreateMemberAsync(db);
  228. var item = await SeedMemberItemAsync(db, ownerID, ItemKind.ChatBanLift);
  229. var handler = new UseHandler(db);
  230. var result = await handler.Handle(new UseCommand(otherID, item.ID), default);
  231. Assert.IsFalse(result.IsSuccess);
  232. Assert.AreEqual("MemberItem.NotFound", result.Error.Code);
  233. }
  234. [TestMethod]
  235. public async Task Use_AlreadyUsedItem_ReturnsConflict()
  236. {
  237. // 기대 행위: 이미 사용한 아이템 재사용은 Conflict(Unavailable).
  238. using var db = TestDb.Create();
  239. var memberID = await TestDb.CreateMemberAsync(db);
  240. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  241. var handler = new UseHandler(db);
  242. var first = await handler.Handle(new UseCommand(memberID, item.ID), default);
  243. Assert.IsTrue(first.IsSuccess);
  244. using var db2 = TestDb.Create();
  245. var handler2 = new UseHandler(db2);
  246. var second = await handler2.Handle(new UseCommand(memberID, item.ID), default);
  247. Assert.IsFalse(second.IsSuccess);
  248. Assert.AreEqual("MemberItem.Unavailable", second.Error.Code);
  249. }
  250. // ── MemberItemConsumer 헬퍼 ───────────────────────────────────
  251. [TestMethod]
  252. public async Task Consumer_TryConsume_MarksUsed_AndLogs_WithoutSaving()
  253. {
  254. // 기대 행위: TryConsume 은 사용 가능한 아이템 1개를 소모하고 로그를 추가한다 (SaveChanges 는 호출측 위임).
  255. using var db = TestDb.Create();
  256. var memberID = await TestDb.CreateMemberAsync(db);
  257. var item = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass);
  258. var now = DateTime.UtcNow;
  259. var consumed = await Application.Helpers.MemberItemConsumer.TryConsumeAsync(db, memberID, ItemKind.NotePass, now, "쪽지 초과 발송", default);
  260. Assert.IsTrue(consumed, "사용 가능한 아이템이 있으면 소모되어야 한다");
  261. await db.SaveChangesAsync(default);
  262. using var verify = TestDb.Create();
  263. Assert.IsFalse((await verify.MemberItem.SingleAsync(i => i.ID == item.ID)).IsAvailable, "소모 후 사용 불가여야 한다");
  264. Assert.AreEqual(1, await verify.MemberItemUsageLog.CountAsync(l => l.MemberItemID == item.ID), "사용 로그가 남아야 한다");
  265. }
  266. [TestMethod]
  267. public async Task Consumer_HasAvailable_And_TryConsume_NoneAvailable()
  268. {
  269. // 기대 행위: 보유가 없으면 HasAvailable=false, TryConsume=false.
  270. using var db = TestDb.Create();
  271. var memberID = await TestDb.CreateMemberAsync(db);
  272. Assert.IsFalse(await Application.Helpers.MemberItemConsumer.HasAvailableAsync(db, memberID, ItemKind.NotePass, default), "보유 없음");
  273. var consumed = await Application.Helpers.MemberItemConsumer.TryConsumeAsync(db, memberID, ItemKind.NotePass, DateTime.UtcNow, null, default);
  274. Assert.IsFalse(consumed, "보유가 없으면 소모 실패");
  275. }
  276. // ── ExpBoostService 헬퍼 ──────────────────────────────────────
  277. [TestMethod]
  278. public async Task ExpBoost_Doubles_WhenActiveBoosterExists_ElseUnchanged()
  279. {
  280. // 기대 행위: 활성 부스터 보유 시 ApplyAsync 는 baseExp × 2, 미보유 시 baseExp 그대로.
  281. using var db = TestDb.Create();
  282. var memberID = await TestDb.CreateMemberAsync(db);
  283. var now = DateTime.UtcNow;
  284. // 부스터 없음 → 그대로
  285. Assert.AreEqual(10, await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, now, default), "부스터 없으면 원본 유지");
  286. // 활성 부스터 사용
  287. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  288. item.MarkUsed(now);
  289. await db.SaveChangesAsync(default);
  290. Assert.AreEqual(20, await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, now, default), "활성 부스터면 2배");
  291. }
  292. [TestMethod]
  293. public async Task ExpBoost_ExpiredBooster_DoesNotDouble()
  294. {
  295. // 기대 행위: 창이 만료된 부스터는 2배를 적용하지 않는다.
  296. using var db = TestDb.Create();
  297. var memberID = await TestDb.CreateMemberAsync(db);
  298. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 1);
  299. var usedAt = DateTime.UtcNow.AddDays(-2);
  300. item.MarkUsed(usedAt);
  301. await db.SaveChangesAsync(default);
  302. var applied = await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, DateTime.UtcNow, default);
  303. Assert.AreEqual(10, applied, "만료 부스터는 2배 미적용");
  304. }
  305. // ── Place 핸들러: 소모품 구매 시 MemberItem 발급 ───────────────
  306. [TestMethod]
  307. public async Task Place_ConsumablePurchase_IssuesOneMemberItemPerUnit()
  308. {
  309. // 기대 행위: ItemKind 상품(쿠폰 없음, GameID null)을 구매하면 수량만큼 계정 귀속 MemberItem 이 발급된다.
  310. using var db = TestDb.Create();
  311. var memberID = await TestDb.CreateMemberAsync(db);
  312. var wallet = Wallet.Create(memberID);
  313. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  314. await db.Wallet.AddAsync(wallet);
  315. await db.SaveChangesAsync(default);
  316. var product = await SeedItemProductAsync(db, ItemKind.ExpBooster, durationDays: 7, price: 1000);
  317. var handler = new PlaceHandler(db);
  318. var result = await handler.Handle(
  319. new PlaceCommand(memberID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 3)]),
  320. default);
  321. Assert.IsTrue(result.IsSuccess, $"소모품 구매는 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Code)}");
  322. Assert.IsTrue(result.Value.HasDigital, "소모품은 Digital 로 취급된다");
  323. using var verify = TestDb.Create();
  324. var items = await verify.MemberItem.Where(i => i.MemberID == memberID && i.ProductID == product.ID).ToListAsync();
  325. Assert.AreEqual(3, items.Count, "구매 수량(3)만큼 MemberItem 이 발급되어야 한다");
  326. Assert.IsTrue(items.All(i => i.Kind == ItemKind.ExpBooster && i.DurationDays == 7 && i.IsAvailable),
  327. "발급된 아이템은 종류·기간 스냅샷이 올바르고 사용 가능해야 한다");
  328. }
  329. // ── List 핸들러 ──────────────────────────────────────────────
  330. [TestMethod]
  331. public async Task List_ExcludesUsedByDefault_AndRevokedAlways()
  332. {
  333. // 기대 행위: 기본 조회는 미사용만, IncludeUsed=true 면 사용분 포함. 회수분은 항상 제외.
  334. using var db = TestDb.Create();
  335. var memberID = await TestDb.CreateMemberAsync(db);
  336. var now = DateTime.UtcNow;
  337. var available = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift);
  338. var used = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  339. used.MarkUsed(now);
  340. var revoked = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass);
  341. revoked.Revoke(now);
  342. await db.SaveChangesAsync(default);
  343. var handler = new ListHandler(db);
  344. var defaultList = await handler.Handle(new ListQuery(memberID), default);
  345. Assert.AreEqual(1, defaultList.Total, "기본 조회는 미사용 1건");
  346. Assert.AreEqual(available.ID, defaultList.List[0].ID);
  347. var withUsed = await handler.Handle(new ListQuery(memberID, IncludeUsed: true), default);
  348. Assert.AreEqual(2, withUsed.Total, "IncludeUsed 는 미사용+사용 2건 (회수분 제외)");
  349. Assert.IsFalse(withUsed.List.Any(r => r.ID == revoked.ID), "회수분은 항상 제외");
  350. }
  351. [TestMethod]
  352. public async Task List_ConsumableOnly_ExcludesStatusItems()
  353. {
  354. // 기대 행위: ConsumableOnly=true (소모품 보관함) 는 소모품(Kind>=ChatBanLift)만, 지위아이템(Badge/NicknameEffect)은 제외.
  355. using var db = TestDb.Create();
  356. var memberID = await TestDb.CreateMemberAsync(db);
  357. var consumable = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift);
  358. await SeedMemberItemAsync(db, memberID, ItemKind.Badge, durationDays: 30);
  359. await SeedMemberItemAsync(db, memberID, ItemKind.NicknameEffect);
  360. var handler = new ListHandler(db);
  361. var all = await handler.Handle(new ListQuery(memberID), default);
  362. Assert.AreEqual(3, all.Total, "기본(전체) 조회는 소모품+지위 3건");
  363. var consumableOnly = await handler.Handle(new ListQuery(memberID, ConsumableOnly: true), default);
  364. Assert.AreEqual(1, consumableOnly.Total, "ConsumableOnly 는 소모품(ChatBanLift) 1건만");
  365. Assert.AreEqual(consumable.ID, consumableOnly.List[0].ID);
  366. }
  367. // ── 도메인: 지위 아이템 (Badge/NicknameEffect/CommentEffect) ────
  368. [TestMethod]
  369. public void Create_StatusItem_SetsEquipSlot_AndExpiresFromDuration()
  370. {
  371. // 기대 행위: 지위 아이템 발급 시 종류에 맞는 슬롯이 설정되고, DurationDays>0 이면 만료 스냅샷이 잡힌다.
  372. var badge = MemberItem.Create(1, 2, 3, ItemKind.Badge, durationDays: 30);
  373. Assert.IsTrue(badge.IsStatusItem, "배지는 지위 아이템");
  374. Assert.AreEqual(EquipSlot.Badge, badge.EquipSlot);
  375. Assert.IsNotNull(badge.ExpiresAt, "기간형 지위 아이템은 만료 스냅샷이 있어야 한다");
  376. Assert.IsFalse(badge.IsEquipped, "발급 직후는 미장착");
  377. var permanent = MemberItem.Create(1, 2, 3, ItemKind.NicknameEffect, durationDays: 0);
  378. Assert.AreEqual(EquipSlot.NicknameEffect, permanent.EquipSlot);
  379. Assert.IsNull(permanent.ExpiresAt, "지속일 0 이면 영구(만료 없음)");
  380. var consumable = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, durationDays: 7);
  381. Assert.IsFalse(consumable.IsStatusItem, "소모품은 지위 아이템 아님");
  382. Assert.IsNull(consumable.EquipSlot);
  383. Assert.IsNull(consumable.ExpiresAt, "소모품은 획득 만료 스냅샷 없음(사용 후 창)");
  384. }
  385. [TestMethod]
  386. public void Equip_ThenUnequip_TogglesFlag_AndRevokeUnequips()
  387. {
  388. // 기대 행위: 지위 아이템은 Equip/Unequip 으로 장착 토글되고, 회수 시 강제 해제된다.
  389. var now = DateTime.UtcNow;
  390. var badge = MemberItem.Create(1, 2, 3, ItemKind.Badge, durationDays: 0);
  391. badge.Equip(now);
  392. Assert.IsTrue(badge.IsEquipped);
  393. badge.Unequip();
  394. Assert.IsFalse(badge.IsEquipped);
  395. badge.Equip(now);
  396. Assert.IsTrue(badge.Revoke(now), "미사용 지위 아이템은 회수 가능");
  397. Assert.IsFalse(badge.IsEquipped, "회수 시 장착 해제되어야 한다");
  398. }
  399. [TestMethod]
  400. public void Equip_ExpiredOrConsumable_Throws()
  401. {
  402. // 기대 행위: 소모품은 장착 불가, 만료된 지위 아이템은 장착 예외.
  403. var now = DateTime.UtcNow;
  404. var consumable = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, durationDays: 7);
  405. Assert.ThrowsException<InvalidOperationException>(() => consumable.Equip(now), "소모품은 장착 불가");
  406. var expired = MemberItem.Create(1, 2, 3, ItemKind.Badge, durationDays: 1);
  407. Assert.ThrowsException<InvalidOperationException>(() => expired.Equip(now.AddDays(2)), "만료된 지위 아이템은 장착 예외");
  408. }
  409. // ── Place 핸들러: 지위 아이템 구매/선물 ────────────────────────
  410. [TestMethod]
  411. public async Task Place_StatusItemPurchase_IssuesEquippableMemberItem()
  412. {
  413. // 기대 행위: 지위 아이템(Badge) 구매 시 장착 가능한(EquipSlot 설정) MemberItem 이 발급된다.
  414. using var db = TestDb.Create();
  415. var memberID = await TestDb.CreateMemberAsync(db);
  416. var wallet = Wallet.Create(memberID);
  417. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  418. await db.Wallet.AddAsync(wallet);
  419. await db.SaveChangesAsync(default);
  420. var product = await SeedItemProductAsync(db, ItemKind.Badge, durationDays: 30, price: 1000, effectPayload: "{\"color\":\"#FFD700\"}");
  421. var handler = new PlaceHandler(db);
  422. var result = await handler.Handle(
  423. new PlaceCommand(memberID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 2)]),
  424. default);
  425. Assert.IsTrue(result.IsSuccess, $"지위 아이템 구매는 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Code)}");
  426. using var verify = TestDb.Create();
  427. var items = await verify.MemberItem.Where(i => i.MemberID == memberID && i.ProductID == product.ID).ToListAsync();
  428. Assert.AreEqual(2, items.Count, "구매 수량만큼 발급");
  429. Assert.IsTrue(items.All(i => i.EquipSlot == EquipSlot.Badge && i.IsStatusItem && i.ExpiresAt != null && !i.IsEquipped),
  430. "발급된 지위 아이템은 슬롯·만료 스냅샷이 올바르고 미장착 상태여야 한다");
  431. }
  432. [TestMethod]
  433. public async Task Place_GiftOrder_IssuesToRecipient_WithGiftFrom()
  434. {
  435. // 기대 행위: 선물 주문은 수신자에게 발급되고 발신자가 GiftFromMemberID 로 기록된다. 주문에도 선물 정보가 남는다.
  436. using var db = TestDb.Create();
  437. var buyerID = await TestDb.CreateMemberAsync(db);
  438. var recipientID = await TestDb.CreateMemberAsync(db);
  439. var wallet = Wallet.Create(buyerID);
  440. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  441. await db.Wallet.AddAsync(wallet);
  442. await db.SaveChangesAsync(default);
  443. var product = await SeedItemProductAsync(db, ItemKind.NicknameEffect, durationDays: 0, price: 1000, isGiftable: true);
  444. var handler = new PlaceHandler(db);
  445. var result = await handler.Handle(
  446. new PlaceCommand(buyerID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 1)], GiftToMemberID: recipientID, GiftMessage: "축하해요"),
  447. default);
  448. Assert.IsTrue(result.IsSuccess, $"선물 주문은 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Code)}");
  449. using var verify = TestDb.Create();
  450. var recipientItems = await verify.MemberItem.Where(i => i.MemberID == recipientID).ToListAsync();
  451. Assert.AreEqual(1, recipientItems.Count, "수신자에게 발급되어야 한다");
  452. Assert.AreEqual(buyerID, recipientItems[0].GiftFromMemberID, "발신자가 기록되어야 한다");
  453. Assert.AreEqual(0, await verify.MemberItem.CountAsync(i => i.MemberID == buyerID), "구매자에게는 발급되지 않아야 한다");
  454. var order = await verify.Order.FirstAsync(o => o.MemberID == buyerID);
  455. Assert.AreEqual(recipientID, order.GiftToMemberID);
  456. Assert.AreEqual("축하해요", order.GiftMessage);
  457. Assert.IsTrue(order.IsGift);
  458. }
  459. [TestMethod]
  460. public async Task Place_GiftOrder_NonGiftableProduct_Fails()
  461. {
  462. // 기대 행위: 선물 불가 상품을 선물하려 하면 거부된다.
  463. using var db = TestDb.Create();
  464. var buyerID = await TestDb.CreateMemberAsync(db);
  465. var recipientID = await TestDb.CreateMemberAsync(db);
  466. var wallet = Wallet.Create(buyerID);
  467. wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED");
  468. await db.Wallet.AddAsync(wallet);
  469. await db.SaveChangesAsync(default);
  470. var product = await SeedItemProductAsync(db, ItemKind.Badge, price: 1000, isGiftable: false);
  471. var handler = new PlaceHandler(db);
  472. var result = await handler.Handle(
  473. new PlaceCommand(buyerID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 1)], GiftToMemberID: recipientID),
  474. default);
  475. Assert.IsFalse(result.IsSuccess);
  476. Assert.AreEqual("Order.NotGiftable", result.Error.Code);
  477. }
  478. // ── Equip / Unequip 핸들러: 슬롯당 1개 ─────────────────────────
  479. [TestMethod]
  480. public async Task Equip_SameSlot_UnequipsPrevious_OnePerSlot()
  481. {
  482. // 기대 행위: 같은 슬롯의 다른 아이템을 장착하면 기존 장착이 자동 해제되어 슬롯당 1개만 유지된다.
  483. using var db = TestDb.Create();
  484. var memberID = await TestDb.CreateMemberAsync(db);
  485. var first = await SeedStatusItemAsync(db, memberID, ItemKind.Badge);
  486. var second = await SeedStatusItemAsync(db, memberID, ItemKind.Badge);
  487. var handler = new EquipHandler(db);
  488. Assert.IsTrue((await handler.Handle(new EquipCommand(memberID, first.ID), default)).IsSuccess);
  489. using var db2 = TestDb.Create();
  490. var handler2 = new EquipHandler(db2);
  491. Assert.IsTrue((await handler2.Handle(new EquipCommand(memberID, second.ID), default)).IsSuccess);
  492. using var verify = TestDb.Create();
  493. var equipped = await verify.MemberItem.Where(i => i.MemberID == memberID && i.EquipSlot == EquipSlot.Badge && i.IsEquipped).ToListAsync();
  494. Assert.AreEqual(1, equipped.Count, "슬롯당 1개만 장착되어야 한다");
  495. Assert.AreEqual(second.ID, equipped[0].ID, "가장 최근 장착이 유지되어야 한다");
  496. }
  497. [TestMethod]
  498. public async Task Equip_DifferentSlots_Coexist_Then_Unequip()
  499. {
  500. // 기대 행위: 서로 다른 슬롯은 동시에 장착되고, Unequip 은 해당 슬롯만 해제한다.
  501. using var db = TestDb.Create();
  502. var memberID = await TestDb.CreateMemberAsync(db);
  503. var badge = await SeedStatusItemAsync(db, memberID, ItemKind.Badge);
  504. var effect = await SeedStatusItemAsync(db, memberID, ItemKind.CommentEffect);
  505. var handler = new EquipHandler(db);
  506. Assert.IsTrue((await handler.Handle(new EquipCommand(memberID, badge.ID), default)).IsSuccess);
  507. using var db2 = TestDb.Create();
  508. Assert.IsTrue((await new EquipHandler(db2).Handle(new EquipCommand(memberID, effect.ID), default)).IsSuccess);
  509. using var db3 = TestDb.Create();
  510. Assert.AreEqual(2, await db3.MemberItem.CountAsync(i => i.MemberID == memberID && i.IsEquipped), "두 슬롯 모두 장착");
  511. using var db4 = TestDb.Create();
  512. Assert.IsTrue((await new UnequipHandler(db4).Handle(new UnequipCommand(memberID, badge.ID), default)).IsSuccess);
  513. using var verify = TestDb.Create();
  514. Assert.IsFalse((await verify.MemberItem.SingleAsync(i => i.ID == badge.ID)).IsEquipped, "배지는 해제");
  515. Assert.IsTrue((await verify.MemberItem.SingleAsync(i => i.ID == effect.ID)).IsEquipped, "댓글 이펙트는 유지");
  516. }
  517. [TestMethod]
  518. public async Task Equip_Consumable_Rejected()
  519. {
  520. // 기대 행위: 소모품은 장착 불가.
  521. using var db = TestDb.Create();
  522. var memberID = await TestDb.CreateMemberAsync(db);
  523. var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7);
  524. var result = await new EquipHandler(db).Handle(new EquipCommand(memberID, item.ID), default);
  525. Assert.IsFalse(result.IsSuccess);
  526. Assert.AreEqual("MemberItem.NotEquippable", result.Error.Code);
  527. }
  528. // ── Equipped 배치 조회 ─────────────────────────────────────────
  529. [TestMethod]
  530. public async Task Equipped_Batch_ReturnsEquippedPerMember_ExcludesExpired()
  531. {
  532. // 기대 행위: 배치 조회는 회원별 장착 아이템만 반환하고, 만료·미장착은 제외한다.
  533. using var db = TestDb.Create();
  534. var m1 = await TestDb.CreateMemberAsync(db);
  535. var m2 = await TestDb.CreateMemberAsync(db);
  536. var badge = await SeedStatusItemAsync(db, m1, ItemKind.Badge);
  537. badge.Equip(DateTime.UtcNow);
  538. // m1 의 미장착 이펙트 (제외 대상)
  539. await SeedStatusItemAsync(db, m1, ItemKind.NicknameEffect);
  540. // m2 의 만료된 장착 아이템 (제외 대상) — 과거 발급이라 이미 만료
  541. var expiredProduct = await SeedItemProductAsync(db, ItemKind.CommentEffect, durationDays: 1);
  542. var expired = MemberItem.Create(m2, expiredProduct.ID, orderItemID: 999999, kind: ItemKind.CommentEffect, durationDays: 1);
  543. expired.Equip(DateTime.UtcNow);
  544. await db.MemberItem.AddAsync(expired);
  545. await db.SaveChangesAsync(default);
  546. // 만료 시각을 과거로 강제
  547. await db.MemberItem.Where(i => i.ID == expired.ID).ExecuteUpdateAsync(s => s.SetProperty(x => x.ExpiresAt, DateTime.UtcNow.AddDays(-1)), default);
  548. var result = await new EquippedHandler(db).Handle(new EquippedQuery([m1, m2]), default);
  549. var m1Row = result.Members.SingleOrDefault(x => x.MemberID == m1);
  550. Assert.IsNotNull(m1Row, "m1 결과가 있어야 한다");
  551. Assert.AreEqual(1, m1Row.Items.Count, "m1 은 장착된 배지 1개만");
  552. Assert.AreEqual(EquipSlot.Badge, m1Row.Items[0].Slot);
  553. Assert.IsFalse(result.Members.Any(x => x.MemberID == m2), "m2 의 만료 장착은 제외되어 결과 없음");
  554. }
  555. // ── List: 만료 필터 + 지위 아이템 상태 ─────────────────────────
  556. [TestMethod]
  557. public async Task List_FiltersExpiredStatusItems_ByDefault()
  558. {
  559. // 기대 행위: 기본 조회는 만료된 지위 아이템을 제외하고, IncludeExpired=true 면 포함한다.
  560. using var db = TestDb.Create();
  561. var memberID = await TestDb.CreateMemberAsync(db);
  562. var active = await SeedStatusItemAsync(db, memberID, ItemKind.Badge);
  563. var expiredProduct = await SeedItemProductAsync(db, ItemKind.NicknameEffect, durationDays: 1);
  564. var expired = MemberItem.Create(memberID, expiredProduct.ID, orderItemID: 999999, kind: ItemKind.NicknameEffect, durationDays: 1);
  565. await db.MemberItem.AddAsync(expired);
  566. await db.SaveChangesAsync(default);
  567. await db.MemberItem.Where(i => i.ID == expired.ID).ExecuteUpdateAsync(s => s.SetProperty(x => x.ExpiresAt, DateTime.UtcNow.AddDays(-1)), default);
  568. var handler = new ListHandler(db);
  569. var defaultList = await handler.Handle(new ListQuery(memberID), default);
  570. Assert.AreEqual(1, defaultList.Total, "만료분 제외 → 1건");
  571. Assert.AreEqual(active.ID, defaultList.List[0].ID);
  572. Assert.IsTrue(defaultList.List[0].IsStatusItem, "지위 아이템 플래그가 참이어야 한다");
  573. var withExpired = await handler.Handle(new ListQuery(memberID, IncludeExpired: true), default);
  574. Assert.AreEqual(2, withExpired.Total, "IncludeExpired=true 면 만료분 포함 2건");
  575. }
  576. // ── 환불: 미사용 지위 아이템 회수 + 장착 해제 ──────────────────
  577. [TestMethod]
  578. public async Task Refund_RevokesUnusedStatusItems_AndUnequips()
  579. {
  580. // 기대 행위: 미사용(장착 포함) 지위 아이템은 도메인 Revoke 로 회수되며 장착이 해제된다 (환불 fallback 미러).
  581. using var db = TestDb.Create();
  582. var memberID = await TestDb.CreateMemberAsync(db);
  583. var item = await SeedStatusItemAsync(db, memberID, ItemKind.Badge);
  584. item.Equip(DateTime.UtcNow);
  585. await db.SaveChangesAsync(default);
  586. // ProcessRefund 의 fallback 분기와 동일한 회수 동작 검증 (미사용 지위 아이템 Revoke)
  587. var target = await db.MemberItem.SingleAsync(i => i.ID == item.ID);
  588. Assert.IsTrue(target.Revoke(DateTime.UtcNow), "미사용 지위 아이템은 회수 가능");
  589. await db.SaveChangesAsync(default);
  590. using var verify = TestDb.Create();
  591. var revoked = await verify.MemberItem.SingleAsync(i => i.ID == item.ID);
  592. Assert.IsNotNull(revoked.RevokedAt, "회수 시각이 기록되어야 한다");
  593. Assert.IsFalse(revoked.IsEquipped, "회수 시 장착 해제되어야 한다");
  594. Assert.IsFalse(revoked.IsAvailable);
  595. }
  596. // ── 지위 아이템 시드 헬퍼 ─────────────────────────────────────
  597. /// <summary>회원 소유의 미장착 지위 아이템을 직접 발급하고 반환한다 (구매 경로 우회).</summary>
  598. private static async Task<MemberItem> SeedStatusItemAsync(AppDbContext db, int memberID, ItemKind kind, int durationDays = 30)
  599. {
  600. var product = await SeedItemProductAsync(db, kind, durationDays, isGiftable: true);
  601. var item = MemberItem.Create(memberID, product.ID, orderItemID: 999999, kind: kind, durationDays: durationDays);
  602. await db.MemberItem.AddAsync(item);
  603. await db.SaveChangesAsync(default);
  604. return item;
  605. }
  606. // ── 테스트 로컬 헬퍼 ──────────────────────────────────────────
  607. private static Task<bool> ChatBanCheckerIsBanned(AppDbContext db, int memberID, string roomKey, DateTime now)
  608. => Application.Helpers.ChatBanChecker.IsBannedAsync(db, memberID, roomKey, now, default);
  609. }