using Domain.Entities.Chat; 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.Store; using Domain.Entities.Store.ValueObject; using Domain.Entities.Wallets; using Domain.Entities.Wallets.ValueObject; using Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using UseCommand = Application.Features.Api.Member.Items.Use.Command; using UseHandler = Application.Features.Api.Member.Items.Use.Handler; using ListHandler = Application.Features.Api.Member.Items.List.Handler; using ListQuery = Application.Features.Api.Member.Items.List.Query; using PlaceCommand = Application.Features.Api.Store.Orders.Place.Command; using PlaceHandler = Application.Features.Api.Store.Orders.Place.Handler; using EquipCommand = Application.Features.Api.Member.Items.Equip.Command; using EquipHandler = Application.Features.Api.Member.Items.Equip.Handler; using UnequipCommand = Application.Features.Api.Member.Items.Unequip.Command; using UnequipHandler = Application.Features.Api.Member.Items.Unequip.Handler; using EquippedQuery = Application.Features.Api.Member.Items.Equipped.Query; using EquippedHandler = Application.Features.Api.Member.Items.Equipped.Handler; namespace Application.Tests; /// /// P3 소모품 상점(계정 귀속 MemberItem) 검증 — 도메인 규칙 + Use/List/Place 핸들러 + 공용 헬퍼. /// 운영과 동일한 LocalDB(antooza_test) SQL Server 프로바이더로 EF 동작을 그대로 검증한다. /// [TestClass] public sealed class MemberItemTests { // ── 시드 헬퍼 ──────────────────────────────────────────────── /// 아이템 상품(Digital + ItemKind)을 시드하고 Product 를 반환한다. private static async Task SeedItemProductAsync(AppDbContext db, ItemKind kind, int durationDays = 0, int price = 0, bool isGiftable = false, string? effectPayload = null) { var product = Product.Create( gameID: null, name: $"아이템-{kind}", type: ProductType.Digital, price: price, discountType: DiscountType.None, discountValue: 0, stock: -1, itemKind: kind, durationDays: durationDays, effectPayload: effectPayload, isGiftable: isGiftable ); await db.Product.AddAsync(product); await db.SaveChangesAsync(default); return product; } /// 회원 소유의 미사용 MemberItem 을 직접 발급하고 반환한다 (구매 경로 우회). private static async Task SeedMemberItemAsync(AppDbContext db, int memberID, ItemKind kind, int durationDays = 0) { var product = await SeedItemProductAsync(db, kind, durationDays); // OrderItemID FK 제약이 없으므로(회수 연동 스냅샷용) 양수 더미로 충분하다. var item = MemberItem.Create(memberID, product.ID, orderItemID: 999999, kind: kind, durationDays: durationDays); await db.MemberItem.AddAsync(item); await db.SaveChangesAsync(default); return item; } /// 본인 소유 활성/미신고/무댓글 게시글 1건을 시드하고 PostID 를 반환한다. private static async Task SeedPostAsync(AppDbContext db, int memberID, int reports = 0, int comments = 0, bool isDeleted = false) { 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 = memberID, Subject = "테스트 글", Content = "본문", Reports = reports, Comments = comments, IsDeleted = isDeleted }; await db.Post.AddAsync(post); await db.SaveChangesAsync(default); return post.ID; } // ── 도메인: MemberItem 라이프사이클 ─────────────────────────── [TestMethod] public void Create_NewItem_IsAvailable_NotUsedNotRevoked() { // 기대 행위: 신규 발급 아이템은 미사용·미회수 → 사용 가능. var item = MemberItem.Create(memberID: 1, productID: 2, orderItemID: 3, kind: ItemKind.NotePass, durationDays: 0); Assert.IsTrue(item.IsAvailable, "신규 아이템은 사용 가능해야 한다"); Assert.IsNull(item.UsedAt); Assert.IsNull(item.RevokedAt); Assert.AreEqual(ItemKind.NotePass, item.Kind); } [TestMethod] public void MarkUsed_ThenAgain_Throws_AndBlocksRevoke() { // 기대 행위: MarkUsed 후 재사용은 예외, 사용한 아이템은 더 이상 회수 불가. var now = DateTime.UtcNow; var item = MemberItem.Create(1, 2, 3, ItemKind.ChatBanLift, 0); item.MarkUsed(now); Assert.IsFalse(item.IsAvailable, "사용 후에는 사용 불가여야 한다"); Assert.AreEqual(now, item.UsedAt); Assert.ThrowsException(() => item.MarkUsed(now), "이미 사용한 아이템 재사용은 예외"); Assert.IsFalse(item.Revoke(now), "사용한 아이템은 회수되지 않아야 한다"); } [TestMethod] public void Revoke_UnusedItem_Succeeds_ThenBlocksUse() { // 기대 행위: 미사용 아이템은 회수 가능하고, 회수 후에는 사용(MarkUsed)이 예외. var now = DateTime.UtcNow; var item = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, 7); Assert.IsTrue(item.Revoke(now), "미사용 아이템은 회수되어야 한다"); Assert.IsFalse(item.IsAvailable); Assert.AreEqual(now, item.RevokedAt); Assert.IsFalse(item.Revoke(now), "이미 회수된 아이템 재회수는 false"); Assert.ThrowsException(() => item.MarkUsed(now), "회수된 아이템은 사용 예외"); } [TestMethod] public void IsBoostActiveAt_OnlyForUsedExpBooster_WithinWindow() { // 기대 행위: ExpBooster 는 사용 후 DurationDays 창 이내에서만 활성. 미사용/타종류/만료/회수는 비활성. var used = DateTime.UtcNow; var booster = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, durationDays: 3); Assert.IsFalse(booster.IsBoostActiveAt(used), "미사용 부스터는 비활성"); booster.MarkUsed(used); Assert.IsTrue(booster.IsBoostActiveAt(used.AddDays(1)), "창 이내는 활성"); Assert.IsFalse(booster.IsBoostActiveAt(used.AddDays(3).AddSeconds(1)), "창 만료 후는 비활성"); var notBooster = MemberItem.Create(1, 2, 3, ItemKind.NotePass, durationDays: 3); notBooster.MarkUsed(used); Assert.IsFalse(notBooster.IsBoostActiveAt(used.AddDays(1)), "부스터가 아니면 항상 비활성"); } // ── Use 핸들러 ─────────────────────────────────────────────── [TestMethod] public async Task Use_ChatBanLift_LiftsActiveBans_AndMarksUsed() { // 기대 행위: 채팅 밴 해제권 사용 시 본인 활성 제재를 해제하고 아이템을 소모 + 사용 로그를 남긴다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); await db.ChatBan.AddAsync(ChatBan.Create(memberID, null, "전체 제재", null, "admin")); await db.ChatBan.AddAsync(ChatBan.Create(memberID, "main", "메인 제재", null, "admin")); await db.SaveChangesAsync(default); var item = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift); var handler = new UseHandler(db); var result = await handler.Handle(new UseCommand(memberID, item.ID), default); Assert.IsTrue(result.IsSuccess, "밴 해제권 사용은 성공해야 한다"); Assert.AreEqual(ItemKind.ChatBanLift, result.Value.Kind); using var verify = TestDb.Create(); var now = DateTime.UtcNow; Assert.IsFalse(await ChatBanCheckerIsBanned(verify, memberID, "main", now), "제재가 해제되어야 한다"); Assert.IsFalse(await ChatBanCheckerIsBanned(verify, memberID, "stock:005930", now), "전체 제재도 해제되어야 한다"); var used = await verify.MemberItem.SingleAsync(i => i.ID == item.ID); Assert.IsFalse(used.IsAvailable, "사용 후 소모 상태여야 한다"); Assert.AreEqual(1, await verify.MemberItemUsageLog.CountAsync(l => l.MemberItemID == item.ID), "사용 로그가 1건 남아야 한다"); } [TestMethod] public async Task Use_ChatBanLift_NoActiveBan_Fails_AndItemNotConsumed() { // 기대 행위: 해제할 활성 제재가 없으면 실패하고 아이템은 소모되지 않는다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var item = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift); var handler = new UseHandler(db); var result = await handler.Handle(new UseCommand(memberID, item.ID), default); Assert.IsFalse(result.IsSuccess, "해제할 제재가 없으면 실패"); Assert.AreEqual("MemberItem.NoActiveBan", result.Error.Code); using var verify = TestDb.Create(); var stored = await verify.MemberItem.SingleAsync(i => i.ID == item.ID); Assert.IsTrue(stored.IsAvailable, "실패 시 아이템은 소모되지 않아야 한다"); } [TestMethod] public async Task Use_PostReset_DeletesOnlyCleanPosts_KeepsReportedOrCommented() { // 기대 행위: 글 초기화권은 신고·댓글 없는 본인 글만 삭제하고, 신고/댓글 있는 글은 남긴다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var cleanPostID = await SeedPostAsync(db, memberID); var reportedPostID = await SeedPostAsync(db, memberID, reports: 2); var commentedPostID = await SeedPostAsync(db, memberID, comments: 3); var item = await SeedMemberItemAsync(db, memberID, ItemKind.PostReset); var handler = new UseHandler(db); var result = await handler.Handle(new UseCommand(memberID, item.ID), default); Assert.IsTrue(result.IsSuccess, "초기화 대상 글이 있으면 성공"); using var verify = TestDb.Create(); Assert.IsTrue((await verify.Post.SingleAsync(p => p.ID == cleanPostID)).IsDeleted, "깨끗한 글은 삭제되어야 한다"); Assert.IsFalse((await verify.Post.SingleAsync(p => p.ID == reportedPostID)).IsDeleted, "신고된 글은 남아야 한다"); Assert.IsFalse((await verify.Post.SingleAsync(p => p.ID == commentedPostID)).IsDeleted, "댓글 달린 글은 남아야 한다"); } [TestMethod] public async Task Use_ExpBooster_MarksUsed_AndActivatesBoost() { // 기대 행위: 경험치 부스터 사용은 MarkUsed 만으로 활성화되고, 이후 활성 판정이 참이 된다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7); var handler = new UseHandler(db); var result = await handler.Handle(new UseCommand(memberID, item.ID), default); Assert.IsTrue(result.IsSuccess, "부스터 사용은 성공해야 한다"); using var verify = TestDb.Create(); var used = await verify.MemberItem.SingleAsync(i => i.ID == item.ID); Assert.IsNotNull(used.UsedAt, "사용 시각이 기록되어야 한다"); Assert.IsTrue(used.IsBoostActiveAt(DateTime.UtcNow), "사용 직후 부스터가 활성이어야 한다"); } [TestMethod] public async Task Use_NotePass_Rejected_NotDirectlyUsable() { // 기대 행위: 쪽지권 등은 직접 사용 불가 — 해당 기능 이용 시 자동 소모되므로 Use 는 거절한다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var item = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass); var handler = new UseHandler(db); var result = await handler.Handle(new UseCommand(memberID, item.ID), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual("MemberItem.NotDirectlyUsable", result.Error.Code); using var verify = TestDb.Create(); Assert.IsTrue((await verify.MemberItem.SingleAsync(i => i.ID == item.ID)).IsAvailable, "거절 시 소모되지 않아야 한다"); } [TestMethod] public async Task Use_NotOwnedItem_ReturnsNotFound() { // 기대 행위: 타인 소유 아이템은 소유자 검증에서 NotFound. using var db = TestDb.Create(); var ownerID = await TestDb.CreateMemberAsync(db); var otherID = await TestDb.CreateMemberAsync(db); var item = await SeedMemberItemAsync(db, ownerID, ItemKind.ChatBanLift); var handler = new UseHandler(db); var result = await handler.Handle(new UseCommand(otherID, item.ID), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual("MemberItem.NotFound", result.Error.Code); } [TestMethod] public async Task Use_AlreadyUsedItem_ReturnsConflict() { // 기대 행위: 이미 사용한 아이템 재사용은 Conflict(Unavailable). using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7); var handler = new UseHandler(db); var first = await handler.Handle(new UseCommand(memberID, item.ID), default); Assert.IsTrue(first.IsSuccess); using var db2 = TestDb.Create(); var handler2 = new UseHandler(db2); var second = await handler2.Handle(new UseCommand(memberID, item.ID), default); Assert.IsFalse(second.IsSuccess); Assert.AreEqual("MemberItem.Unavailable", second.Error.Code); } // ── MemberItemConsumer 헬퍼 ─────────────────────────────────── [TestMethod] public async Task Consumer_TryConsume_MarksUsed_AndLogs_WithoutSaving() { // 기대 행위: TryConsume 은 사용 가능한 아이템 1개를 소모하고 로그를 추가한다 (SaveChanges 는 호출측 위임). using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var item = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass); var now = DateTime.UtcNow; var consumed = await Application.Helpers.MemberItemConsumer.TryConsumeAsync(db, memberID, ItemKind.NotePass, now, "쪽지 초과 발송", default); Assert.IsTrue(consumed, "사용 가능한 아이템이 있으면 소모되어야 한다"); await db.SaveChangesAsync(default); using var verify = TestDb.Create(); Assert.IsFalse((await verify.MemberItem.SingleAsync(i => i.ID == item.ID)).IsAvailable, "소모 후 사용 불가여야 한다"); Assert.AreEqual(1, await verify.MemberItemUsageLog.CountAsync(l => l.MemberItemID == item.ID), "사용 로그가 남아야 한다"); } [TestMethod] public async Task Consumer_HasAvailable_And_TryConsume_NoneAvailable() { // 기대 행위: 보유가 없으면 HasAvailable=false, TryConsume=false. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); Assert.IsFalse(await Application.Helpers.MemberItemConsumer.HasAvailableAsync(db, memberID, ItemKind.NotePass, default), "보유 없음"); var consumed = await Application.Helpers.MemberItemConsumer.TryConsumeAsync(db, memberID, ItemKind.NotePass, DateTime.UtcNow, null, default); Assert.IsFalse(consumed, "보유가 없으면 소모 실패"); } // ── ExpBoostService 헬퍼 ────────────────────────────────────── [TestMethod] public async Task ExpBoost_Doubles_WhenActiveBoosterExists_ElseUnchanged() { // 기대 행위: 활성 부스터 보유 시 ApplyAsync 는 baseExp × 2, 미보유 시 baseExp 그대로. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var now = DateTime.UtcNow; // 부스터 없음 → 그대로 Assert.AreEqual(10, await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, now, default), "부스터 없으면 원본 유지"); // 활성 부스터 사용 var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7); item.MarkUsed(now); await db.SaveChangesAsync(default); Assert.AreEqual(20, await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, now, default), "활성 부스터면 2배"); } [TestMethod] public async Task ExpBoost_ExpiredBooster_DoesNotDouble() { // 기대 행위: 창이 만료된 부스터는 2배를 적용하지 않는다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 1); var usedAt = DateTime.UtcNow.AddDays(-2); item.MarkUsed(usedAt); await db.SaveChangesAsync(default); var applied = await Application.Helpers.ExpBoostService.ApplyAsync(db, memberID, 10, DateTime.UtcNow, default); Assert.AreEqual(10, applied, "만료 부스터는 2배 미적용"); } // ── Place 핸들러: 소모품 구매 시 MemberItem 발급 ─────────────── [TestMethod] public async Task Place_ConsumablePurchase_IssuesOneMemberItemPerUnit() { // 기대 행위: ItemKind 상품(쿠폰 없음, GameID null)을 구매하면 수량만큼 계정 귀속 MemberItem 이 발급된다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var wallet = Wallet.Create(memberID); wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED"); await db.Wallet.AddAsync(wallet); await db.SaveChangesAsync(default); var product = await SeedItemProductAsync(db, ItemKind.ExpBooster, durationDays: 7, price: 1000); var handler = new PlaceHandler(db); var result = await handler.Handle( new PlaceCommand(memberID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 3)]), default); Assert.IsTrue(result.IsSuccess, $"소모품 구매는 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Code)}"); Assert.IsTrue(result.Value.HasDigital, "소모품은 Digital 로 취급된다"); using var verify = TestDb.Create(); var items = await verify.MemberItem.Where(i => i.MemberID == memberID && i.ProductID == product.ID).ToListAsync(); Assert.AreEqual(3, items.Count, "구매 수량(3)만큼 MemberItem 이 발급되어야 한다"); Assert.IsTrue(items.All(i => i.Kind == ItemKind.ExpBooster && i.DurationDays == 7 && i.IsAvailable), "발급된 아이템은 종류·기간 스냅샷이 올바르고 사용 가능해야 한다"); } // ── List 핸들러 ────────────────────────────────────────────── [TestMethod] public async Task List_ExcludesUsedByDefault_AndRevokedAlways() { // 기대 행위: 기본 조회는 미사용만, IncludeUsed=true 면 사용분 포함. 회수분은 항상 제외. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var now = DateTime.UtcNow; var available = await SeedMemberItemAsync(db, memberID, ItemKind.ChatBanLift); var used = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7); used.MarkUsed(now); var revoked = await SeedMemberItemAsync(db, memberID, ItemKind.NotePass); revoked.Revoke(now); await db.SaveChangesAsync(default); var handler = new ListHandler(db); var defaultList = await handler.Handle(new ListQuery(memberID), default); Assert.AreEqual(1, defaultList.Total, "기본 조회는 미사용 1건"); Assert.AreEqual(available.ID, defaultList.List[0].ID); var withUsed = await handler.Handle(new ListQuery(memberID, IncludeUsed: true), default); Assert.AreEqual(2, withUsed.Total, "IncludeUsed 는 미사용+사용 2건 (회수분 제외)"); Assert.IsFalse(withUsed.List.Any(r => r.ID == revoked.ID), "회수분은 항상 제외"); } // ── 도메인: 지위 아이템 (Badge/NicknameEffect/CommentEffect) ──── [TestMethod] public void Create_StatusItem_SetsEquipSlot_AndExpiresFromDuration() { // 기대 행위: 지위 아이템 발급 시 종류에 맞는 슬롯이 설정되고, DurationDays>0 이면 만료 스냅샷이 잡힌다. var badge = MemberItem.Create(1, 2, 3, ItemKind.Badge, durationDays: 30); Assert.IsTrue(badge.IsStatusItem, "배지는 지위 아이템"); Assert.AreEqual(EquipSlot.Badge, badge.EquipSlot); Assert.IsNotNull(badge.ExpiresAt, "기간형 지위 아이템은 만료 스냅샷이 있어야 한다"); Assert.IsFalse(badge.IsEquipped, "발급 직후는 미장착"); var permanent = MemberItem.Create(1, 2, 3, ItemKind.NicknameEffect, durationDays: 0); Assert.AreEqual(EquipSlot.NicknameEffect, permanent.EquipSlot); Assert.IsNull(permanent.ExpiresAt, "지속일 0 이면 영구(만료 없음)"); var consumable = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, durationDays: 7); Assert.IsFalse(consumable.IsStatusItem, "소모품은 지위 아이템 아님"); Assert.IsNull(consumable.EquipSlot); Assert.IsNull(consumable.ExpiresAt, "소모품은 획득 만료 스냅샷 없음(사용 후 창)"); } [TestMethod] public void Equip_ThenUnequip_TogglesFlag_AndRevokeUnequips() { // 기대 행위: 지위 아이템은 Equip/Unequip 으로 장착 토글되고, 회수 시 강제 해제된다. var now = DateTime.UtcNow; var badge = MemberItem.Create(1, 2, 3, ItemKind.Badge, durationDays: 0); badge.Equip(now); Assert.IsTrue(badge.IsEquipped); badge.Unequip(); Assert.IsFalse(badge.IsEquipped); badge.Equip(now); Assert.IsTrue(badge.Revoke(now), "미사용 지위 아이템은 회수 가능"); Assert.IsFalse(badge.IsEquipped, "회수 시 장착 해제되어야 한다"); } [TestMethod] public void Equip_ExpiredOrConsumable_Throws() { // 기대 행위: 소모품은 장착 불가, 만료된 지위 아이템은 장착 예외. var now = DateTime.UtcNow; var consumable = MemberItem.Create(1, 2, 3, ItemKind.ExpBooster, durationDays: 7); Assert.ThrowsException(() => consumable.Equip(now), "소모품은 장착 불가"); var expired = MemberItem.Create(1, 2, 3, ItemKind.Badge, durationDays: 1); Assert.ThrowsException(() => expired.Equip(now.AddDays(2)), "만료된 지위 아이템은 장착 예외"); } // ── Place 핸들러: 지위 아이템 구매/선물 ──────────────────────── [TestMethod] public async Task Place_StatusItemPurchase_IssuesEquippableMemberItem() { // 기대 행위: 지위 아이템(Badge) 구매 시 장착 가능한(EquipSlot 설정) MemberItem 이 발급된다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var wallet = Wallet.Create(memberID); wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED"); await db.Wallet.AddAsync(wallet); await db.SaveChangesAsync(default); var product = await SeedItemProductAsync(db, ItemKind.Badge, durationDays: 30, price: 1000, effectPayload: "{\"color\":\"#FFD700\"}"); var handler = new PlaceHandler(db); var result = await handler.Handle( new PlaceCommand(memberID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 2)]), default); Assert.IsTrue(result.IsSuccess, $"지위 아이템 구매는 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Code)}"); using var verify = TestDb.Create(); var items = await verify.MemberItem.Where(i => i.MemberID == memberID && i.ProductID == product.ID).ToListAsync(); Assert.AreEqual(2, items.Count, "구매 수량만큼 발급"); Assert.IsTrue(items.All(i => i.EquipSlot == EquipSlot.Badge && i.IsStatusItem && i.ExpiresAt != null && !i.IsEquipped), "발급된 지위 아이템은 슬롯·만료 스냅샷이 올바르고 미장착 상태여야 한다"); } [TestMethod] public async Task Place_GiftOrder_IssuesToRecipient_WithGiftFrom() { // 기대 행위: 선물 주문은 수신자에게 발급되고 발신자가 GiftFromMemberID 로 기록된다. 주문에도 선물 정보가 남는다. using var db = TestDb.Create(); var buyerID = await TestDb.CreateMemberAsync(db); var recipientID = await TestDb.CreateMemberAsync(db); var wallet = Wallet.Create(buyerID); wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED"); await db.Wallet.AddAsync(wallet); await db.SaveChangesAsync(default); var product = await SeedItemProductAsync(db, ItemKind.NicknameEffect, durationDays: 0, price: 1000, isGiftable: true); var handler = new PlaceHandler(db); var result = await handler.Handle( new PlaceCommand(buyerID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 1)], GiftToMemberID: recipientID, GiftMessage: "축하해요"), default); Assert.IsTrue(result.IsSuccess, $"선물 주문은 성공해야 한다: {(result.IsSuccess ? "" : result.Error.Code)}"); using var verify = TestDb.Create(); var recipientItems = await verify.MemberItem.Where(i => i.MemberID == recipientID).ToListAsync(); Assert.AreEqual(1, recipientItems.Count, "수신자에게 발급되어야 한다"); Assert.AreEqual(buyerID, recipientItems[0].GiftFromMemberID, "발신자가 기록되어야 한다"); Assert.AreEqual(0, await verify.MemberItem.CountAsync(i => i.MemberID == buyerID), "구매자에게는 발급되지 않아야 한다"); var order = await verify.Order.FirstAsync(o => o.MemberID == buyerID); Assert.AreEqual(recipientID, order.GiftToMemberID); Assert.AreEqual("축하해요", order.GiftMessage); Assert.IsTrue(order.IsGift); } [TestMethod] public async Task Place_GiftOrder_NonGiftableProduct_Fails() { // 기대 행위: 선물 불가 상품을 선물하려 하면 거부된다. using var db = TestDb.Create(); var buyerID = await TestDb.CreateMemberAsync(db); var recipientID = await TestDb.CreateMemberAsync(db); var wallet = Wallet.Create(buyerID); wallet.CreditPgCharge(Money.KRW(10000), "충전", "SEED"); await db.Wallet.AddAsync(wallet); await db.SaveChangesAsync(default); var product = await SeedItemProductAsync(db, ItemKind.Badge, price: 1000, isGiftable: false); var handler = new PlaceHandler(db); var result = await handler.Handle( new PlaceCommand(buyerID, ChannelID: null, Items: [new PlaceCommand.Item(product.ID, 1)], GiftToMemberID: recipientID), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual("Order.NotGiftable", result.Error.Code); } // ── Equip / Unequip 핸들러: 슬롯당 1개 ───────────────────────── [TestMethod] public async Task Equip_SameSlot_UnequipsPrevious_OnePerSlot() { // 기대 행위: 같은 슬롯의 다른 아이템을 장착하면 기존 장착이 자동 해제되어 슬롯당 1개만 유지된다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var first = await SeedStatusItemAsync(db, memberID, ItemKind.Badge); var second = await SeedStatusItemAsync(db, memberID, ItemKind.Badge); var handler = new EquipHandler(db); Assert.IsTrue((await handler.Handle(new EquipCommand(memberID, first.ID), default)).IsSuccess); using var db2 = TestDb.Create(); var handler2 = new EquipHandler(db2); Assert.IsTrue((await handler2.Handle(new EquipCommand(memberID, second.ID), default)).IsSuccess); using var verify = TestDb.Create(); var equipped = await verify.MemberItem.Where(i => i.MemberID == memberID && i.EquipSlot == EquipSlot.Badge && i.IsEquipped).ToListAsync(); Assert.AreEqual(1, equipped.Count, "슬롯당 1개만 장착되어야 한다"); Assert.AreEqual(second.ID, equipped[0].ID, "가장 최근 장착이 유지되어야 한다"); } [TestMethod] public async Task Equip_DifferentSlots_Coexist_Then_Unequip() { // 기대 행위: 서로 다른 슬롯은 동시에 장착되고, Unequip 은 해당 슬롯만 해제한다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var badge = await SeedStatusItemAsync(db, memberID, ItemKind.Badge); var effect = await SeedStatusItemAsync(db, memberID, ItemKind.CommentEffect); var handler = new EquipHandler(db); Assert.IsTrue((await handler.Handle(new EquipCommand(memberID, badge.ID), default)).IsSuccess); using var db2 = TestDb.Create(); Assert.IsTrue((await new EquipHandler(db2).Handle(new EquipCommand(memberID, effect.ID), default)).IsSuccess); using var db3 = TestDb.Create(); Assert.AreEqual(2, await db3.MemberItem.CountAsync(i => i.MemberID == memberID && i.IsEquipped), "두 슬롯 모두 장착"); using var db4 = TestDb.Create(); Assert.IsTrue((await new UnequipHandler(db4).Handle(new UnequipCommand(memberID, badge.ID), default)).IsSuccess); using var verify = TestDb.Create(); Assert.IsFalse((await verify.MemberItem.SingleAsync(i => i.ID == badge.ID)).IsEquipped, "배지는 해제"); Assert.IsTrue((await verify.MemberItem.SingleAsync(i => i.ID == effect.ID)).IsEquipped, "댓글 이펙트는 유지"); } [TestMethod] public async Task Equip_Consumable_Rejected() { // 기대 행위: 소모품은 장착 불가. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var item = await SeedMemberItemAsync(db, memberID, ItemKind.ExpBooster, durationDays: 7); var result = await new EquipHandler(db).Handle(new EquipCommand(memberID, item.ID), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual("MemberItem.NotEquippable", result.Error.Code); } // ── Equipped 배치 조회 ───────────────────────────────────────── [TestMethod] public async Task Equipped_Batch_ReturnsEquippedPerMember_ExcludesExpired() { // 기대 행위: 배치 조회는 회원별 장착 아이템만 반환하고, 만료·미장착은 제외한다. using var db = TestDb.Create(); var m1 = await TestDb.CreateMemberAsync(db); var m2 = await TestDb.CreateMemberAsync(db); var badge = await SeedStatusItemAsync(db, m1, ItemKind.Badge); badge.Equip(DateTime.UtcNow); // m1 의 미장착 이펙트 (제외 대상) await SeedStatusItemAsync(db, m1, ItemKind.NicknameEffect); // m2 의 만료된 장착 아이템 (제외 대상) — 과거 발급이라 이미 만료 var expiredProduct = await SeedItemProductAsync(db, ItemKind.CommentEffect, durationDays: 1); var expired = MemberItem.Create(m2, expiredProduct.ID, orderItemID: 999999, kind: ItemKind.CommentEffect, durationDays: 1); expired.Equip(DateTime.UtcNow); await db.MemberItem.AddAsync(expired); await db.SaveChangesAsync(default); // 만료 시각을 과거로 강제 await db.MemberItem.Where(i => i.ID == expired.ID).ExecuteUpdateAsync(s => s.SetProperty(x => x.ExpiresAt, DateTime.UtcNow.AddDays(-1)), default); var result = await new EquippedHandler(db).Handle(new EquippedQuery([m1, m2]), default); var m1Row = result.Members.SingleOrDefault(x => x.MemberID == m1); Assert.IsNotNull(m1Row, "m1 결과가 있어야 한다"); Assert.AreEqual(1, m1Row.Items.Count, "m1 은 장착된 배지 1개만"); Assert.AreEqual(EquipSlot.Badge, m1Row.Items[0].Slot); Assert.IsFalse(result.Members.Any(x => x.MemberID == m2), "m2 의 만료 장착은 제외되어 결과 없음"); } // ── List: 만료 필터 + 지위 아이템 상태 ───────────────────────── [TestMethod] public async Task List_FiltersExpiredStatusItems_ByDefault() { // 기대 행위: 기본 조회는 만료된 지위 아이템을 제외하고, IncludeExpired=true 면 포함한다. using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var active = await SeedStatusItemAsync(db, memberID, ItemKind.Badge); var expiredProduct = await SeedItemProductAsync(db, ItemKind.NicknameEffect, durationDays: 1); var expired = MemberItem.Create(memberID, expiredProduct.ID, orderItemID: 999999, kind: ItemKind.NicknameEffect, durationDays: 1); await db.MemberItem.AddAsync(expired); await db.SaveChangesAsync(default); await db.MemberItem.Where(i => i.ID == expired.ID).ExecuteUpdateAsync(s => s.SetProperty(x => x.ExpiresAt, DateTime.UtcNow.AddDays(-1)), default); var handler = new ListHandler(db); var defaultList = await handler.Handle(new ListQuery(memberID), default); Assert.AreEqual(1, defaultList.Total, "만료분 제외 → 1건"); Assert.AreEqual(active.ID, defaultList.List[0].ID); Assert.IsTrue(defaultList.List[0].IsStatusItem, "지위 아이템 플래그가 참이어야 한다"); var withExpired = await handler.Handle(new ListQuery(memberID, IncludeExpired: true), default); Assert.AreEqual(2, withExpired.Total, "IncludeExpired=true 면 만료분 포함 2건"); } // ── 환불: 미사용 지위 아이템 회수 + 장착 해제 ────────────────── [TestMethod] public async Task Refund_RevokesUnusedStatusItems_AndUnequips() { // 기대 행위: 미사용(장착 포함) 지위 아이템은 도메인 Revoke 로 회수되며 장착이 해제된다 (환불 fallback 미러). using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var item = await SeedStatusItemAsync(db, memberID, ItemKind.Badge); item.Equip(DateTime.UtcNow); await db.SaveChangesAsync(default); // ProcessRefund 의 fallback 분기와 동일한 회수 동작 검증 (미사용 지위 아이템 Revoke) var target = await db.MemberItem.SingleAsync(i => i.ID == item.ID); Assert.IsTrue(target.Revoke(DateTime.UtcNow), "미사용 지위 아이템은 회수 가능"); await db.SaveChangesAsync(default); using var verify = TestDb.Create(); var revoked = await verify.MemberItem.SingleAsync(i => i.ID == item.ID); Assert.IsNotNull(revoked.RevokedAt, "회수 시각이 기록되어야 한다"); Assert.IsFalse(revoked.IsEquipped, "회수 시 장착 해제되어야 한다"); Assert.IsFalse(revoked.IsAvailable); } // ── 지위 아이템 시드 헬퍼 ───────────────────────────────────── /// 회원 소유의 미장착 지위 아이템을 직접 발급하고 반환한다 (구매 경로 우회). private static async Task SeedStatusItemAsync(AppDbContext db, int memberID, ItemKind kind, int durationDays = 30) { var product = await SeedItemProductAsync(db, kind, durationDays, isGiftable: true); var item = MemberItem.Create(memberID, product.ID, orderItemID: 999999, kind: kind, durationDays: durationDays); await db.MemberItem.AddAsync(item); await db.SaveChangesAsync(default); return item; } // ── 테스트 로컬 헬퍼 ────────────────────────────────────────── private static Task ChatBanCheckerIsBanned(AppDbContext db, int memberID, string roomKey, DateTime now) => Application.Helpers.ChatBanChecker.IsBannedAsync(db, memberID, roomKey, now, default); }