소스 검색

feat(store): P3 소모품 상점 백엔드 마무리 — 라우트 정렬·Admin·테스트 (Wave 3A)

- 엔드포인트 라우트 api/members -> api/member (프론트 member 프록시 규약 정렬)
- Admin 상품 등록/수정: GameID 선택화 + ItemKind/DurationDays 필드 (소모품은 게임 없이 등록)
- Config Update 의 ChangeThumbDay 배선 회귀 (시드 90 유지, 설정 저장 시 널 덮어쓰기 방지) — Get 은 유지
- MemberItemTests 17종 추가 (도메인·사용·소모·부스터·환불 회수), 테스트 82/82 그린

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 3 주 전
부모
커밋
b775f94cba

+ 3 - 3
Admin/Pages/Store/Product/Edit.cshtml

@@ -16,10 +16,10 @@
         <input type="hidden" asp-for="QueryString" />
 
         <div class="row mb-2">
-            <label asp-for="Input.GameID" class="col-sm-2 col-form-label"><span>*</span> 게임</label>
+            <label asp-for="Input.GameID" class="col-sm-2 col-form-label">게임</label>
             <div class="col-sm-10">
-                <select asp-for="Input.GameID" asp-items="Model.Games" class="form-select" required>
-                    <option value="">게임을 선택하세요</option>
+                <select asp-for="Input.GameID" asp-items="Model.Games" class="form-select">
+                    <option value="">게임 없음 (플랫폼 소모품)</option>
                 </select>
                 <span asp-validation-for="Input.GameID" class="text-danger"></span>
             </div>

+ 2 - 4
Admin/Pages/Store/Product/Edit.cshtml.cs

@@ -29,9 +29,7 @@ public class EditModel(IMediator mediator) : PageModel
         public int ID { get; set; }
 
         [DisplayName("게임")]
-        [Required(ErrorMessage = "{0}을(를) 선택하세요.")]
-        [Range(1, int.MaxValue, ErrorMessage = "게임을 선택해 주세요.")]
-        public int GameID { get; set; }
+        public int? GameID { get; set; }
 
         [DisplayName("상품명")]
         [Required(ErrorMessage = "{0}은(는) 필수입니다.")]
@@ -113,7 +111,7 @@ public class EditModel(IMediator mediator) : PageModel
         Input = new InputModel
         {
             ID = data.ID,
-            GameID = data.GameID,
+            GameID = data.GameID > 0 ? data.GameID : (int?)null,
             Name = data.Name,
             Description = data.Description,
             Type = data.Type,

+ 24 - 3
Admin/Pages/Store/Product/Write.cshtml

@@ -13,10 +13,10 @@
 
     <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
         <div class="row mb-2">
-            <label asp-for="Input.GameID" class="col-sm-2 col-form-label"><span>*</span> 게임</label>
+            <label asp-for="Input.GameID" class="col-sm-2 col-form-label">게임</label>
             <div class="col-sm-10">
-                <select asp-for="Input.GameID" asp-items="Model.Games" class="form-select" required>
-                    <option value="">게임을 선택하세요</option>
+                <select asp-for="Input.GameID" asp-items="Model.Games" class="form-select">
+                    <option value="">게임 없음 (플랫폼 소모품)</option>
                 </select>
                 <span asp-validation-for="Input.GameID" class="text-danger"></span>
             </div>
@@ -188,6 +188,27 @@
                 </div>
             </div>
         </div>
+        <div class="row mb-2">
+            <label asp-for="Input.ItemKind" class="col-sm-2 col-form-label">소모품 종류</label>
+            <div class="col-sm-10">
+                <select asp-for="Input.ItemKind" asp-items="Html.GetEnumSelectList<ItemKind>()" class="form-select">
+                    <option value="">일반 상품 (소모품 아님)</option>
+                </select>
+                <small class="form-text text-muted">선택 시 구매하면 쿠폰 대신 계정 귀속 아이템이 발급됩니다. 소모품은 게임 선택 없이 등록하세요.</small>
+                <span asp-validation-for="Input.ItemKind" class="text-danger"></span>
+            </div>
+        </div>
+        <div class="row mb-2">
+            <label asp-for="Input.DurationDays" class="col-sm-2 col-form-label">소모품 지속일</label>
+            <div class="col-sm-10">
+                <div class="input-group" style="max-width:220px">
+                    <input asp-for="Input.DurationDays" class="form-control" type="number" min="0" max="3650" />
+                    <span class="input-group-text">일</span>
+                </div>
+                <small class="form-text text-muted">경험치 2배 모래시계 등 기간형 아이템의 효과 지속일 (0 = 즉시 소모형)</small>
+                <span asp-validation-for="Input.DurationDays" class="text-danger"></span>
+            </div>
+        </div>
 
         <hr />
 

+ 11 - 4
Admin/Pages/Store/Product/Write.cshtml.cs

@@ -20,9 +20,7 @@ public class WriteModel(IMediator mediator) : PageModel
     public sealed class InputModel
     {
         [DisplayName("게임")]
-        [Required(ErrorMessage = "{0}은(는) 필수입니다.")]
-        [Range(1, int.MaxValue, ErrorMessage = "게임을 선택해 주세요.")]
-        public int GameID { get; set; }
+        public int? GameID { get; set; }
 
         [DisplayName("상품명")]
         [Required(ErrorMessage = "{0}은(는) 필수입니다.")]
@@ -81,6 +79,13 @@ public class WriteModel(IMediator mediator) : PageModel
 
         [DisplayName("후원 채널 필수")]
         public bool RequireDonationChannel { get; set; }
+
+        [DisplayName("소모품 종류")]
+        public ItemKind? ItemKind { get; set; }
+
+        [DisplayName("소모품 효과 지속일")]
+        [Range(0, 3650)]
+        public int DurationDays { get; set; }
     }
 
     public async Task OnGetAsync(CancellationToken ct)
@@ -121,7 +126,9 @@ public class WriteModel(IMediator mediator) : PageModel
             Input.IsActive,
             saleStartUtc,
             saleEndUtc,
-            Input.RequireDonationChannel
+            Input.RequireDonationChannel,
+            Input.ItemKind,
+            Input.DurationDays
         ), ct);
 
         if (result.IsFailure)

+ 4 - 2
Application/Features/Admin/Store/Product/Create/Command.cs

@@ -6,7 +6,7 @@ using SharedKernel.Results;
 namespace Application.Features.Admin.Store.Product.Create;
 
 public sealed record Command(
-    int GameID,
+    int? GameID,
     string Name,
     IFormFile? ThumbnailFile,
     string? Description,
@@ -21,5 +21,7 @@ public sealed record Command(
     bool IsActive,
     DateTime? SaleStartAt,
     DateTime? SaleEndAt,
-    bool RequireDonationChannel
+    bool RequireDonationChannel,
+    ItemKind? ItemKind,
+    int DurationDays
 ) : ICommand<Result<int>>;

+ 8 - 4
Application/Features/Admin/Store/Product/Create/Handler.cs

@@ -14,8 +14,10 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
 
     public async Task<Result<int>> Handle(Command request, CancellationToken ct)
     {
-        // Game 존재 검증 (사용자 정책: GameID 필수)
-        if (!await db.Game.AnyAsync(c => c.ID == request.GameID && c.IsActive, ct))
+        // Game 존재 검증 — 소모품(ItemKind)은 게임 없이 등록 가능
+        var gameID = request.GameID is > 0 ? request.GameID : null;
+
+        if (gameID.HasValue && !await db.Game.AnyAsync(c => c.ID == gameID.Value && c.IsActive, ct))
         {
             return Result.Failure<int>(Error.NotFound("Product.GameNotFound", "선택한 게임이 없거나 비활성 상태입니다."));
         }
@@ -51,7 +53,7 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         }
 
         var product = ProductEntity.Create(
-            gameID: request.GameID,
+            gameID: gameID,
             name: request.Name,
             type: request.Type,
             price: request.Price,
@@ -64,7 +66,9 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
             maxPurchase: request.MaxPurchase,
             saleStartAt: request.SaleStartAt,
             saleEndAt: request.SaleEndAt,
-            requireDonationChannel: request.RequireDonationChannel
+            requireDonationChannel: request.RequireDonationChannel,
+            itemKind: request.ItemKind,
+            durationDays: request.DurationDays
         );
 
         if (!request.IsActive)

+ 1 - 1
Application/Features/Admin/Store/Product/Update/Command.cs

@@ -7,7 +7,7 @@ namespace Application.Features.Admin.Store.Product.Update;
 
 public sealed record Command(
     int ID,
-    int GameID,
+    int? GameID,
     string Name,
     IFormFile? ThumbnailFile,
     bool RemoveThumbnail,

+ 4 - 2
Application/Features/Admin/Store/Product/Update/Handler.cs

@@ -13,7 +13,9 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
 
     public async Task<Result> Handle(Command request, CancellationToken ct)
     {
-        if (!await db.Game.AnyAsync(c => c.ID == request.GameID, ct))
+        var gameID = request.GameID is > 0 ? request.GameID : null;
+
+        if (gameID.HasValue && !await db.Game.AnyAsync(c => c.ID == gameID.Value, ct))
         {
             return Result.Failure(Error.NotFound("Product.GameNotFound", "선택한 게임이 없습니다."));
         }
@@ -74,7 +76,7 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         }
 
         product.Update(
-            gameID: request.GameID,
+            gameID: gameID,
             name: request.Name,
             type: request.Type,
             price: request.Price,

+ 0 - 1
Application/Features/Config/Update/Handler.cs

@@ -119,7 +119,6 @@ public sealed class Handler(IAppDbContext db, IFileStorage storage, IEditorImage
             account.ChangeNameDay = request.Account.ChangeNameDay;
             account.ChangeSummaryDay = request.Account.ChangeSummaryDay;
             account.ChangeIntroDay = request.Account.ChangeIntroDay;
-            account.ChangeThumbDay = request.Account.ChangeThumbDay;
             account.ChangePasswordDay = request.Account.ChangePasswordDay;
             account.IsLoginEmailVerifiedOnly = request.Account.IsLoginEmailVerifiedOnly;
             account.MaxLoginTryCount = request.Account.MaxLoginTryCount;

+ 0 - 5
Application/Features/Config/Update/Request.cs

@@ -321,10 +321,6 @@ public sealed class Request
         [DisplayName("회원가입 시 - 자기소개 갱신 주기")]
         public ushort? ChangeIntroDay { get; init; }
 
-        [Range(0, 365)]
-        [DisplayName("회원가입 시 - 프로필 이미지 갱신 주기")]
-        public ushort? ChangeThumbDay { get; init; }
-
         [Range(0, 365)]
         [DisplayName("회원가입 시 - 비밀번호 갱신 주기")]
         public ushort? ChangePasswordDay { get; init; }
@@ -597,7 +593,6 @@ public sealed class Request
             ChangeNameDay = src.Account.ChangeNameDay,
             ChangeSummaryDay = src.Account.ChangeSummaryDay,
             ChangeIntroDay = src.Account.ChangeIntroDay,
-            ChangeThumbDay = src.Account.ChangeThumbDay,
             ChangePasswordDay = src.Account.ChangePasswordDay,
             IsLoginEmailVerifiedOnly = src.Account.IsLoginEmailVerifiedOnly,
             MaxLoginTryCount = src.Account.MaxLoginTryCount,

+ 425 - 0
Tests/Application.Tests/MemberItemTests.cs

@@ -0,0 +1,425 @@
+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.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;
+
+namespace Application.Tests;
+
+/// <summary>
+/// P3 소모품 상점(계정 귀속 MemberItem) 검증 — 도메인 규칙 + Use/List/Place 핸들러 + 공용 헬퍼.
+/// 운영과 동일한 LocalDB(antooza_test) SQL Server 프로바이더로 EF 동작을 그대로 검증한다.
+/// </summary>
+[TestClass]
+public sealed class MemberItemTests
+{
+    // ── 시드 헬퍼 ────────────────────────────────────────────────
+
+    /// <summary>소모품 상품(Digital + ItemKind)을 시드하고 Product 를 반환한다.</summary>
+    private static async Task<Product> SeedItemProductAsync(AppDbContext db, ItemKind kind, int durationDays = 0, int price = 0)
+    {
+        var product = Product.Create(
+            gameID: null,
+            name: $"소모품-{kind}",
+            type: ProductType.Digital,
+            price: price,
+            discountType: DiscountType.None,
+            discountValue: 0,
+            stock: -1,
+            itemKind: kind,
+            durationDays: durationDays
+        );
+
+        await db.Product.AddAsync(product);
+        await db.SaveChangesAsync(default);
+        return product;
+    }
+
+    /// <summary>회원 소유의 미사용 MemberItem 을 직접 발급하고 반환한다 (구매 경로 우회).</summary>
+    private static async Task<MemberItem> 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;
+    }
+
+    /// <summary>본인 소유 활성/미신고/무댓글 게시글 1건을 시드하고 PostID 를 반환한다.</summary>
+    private static async Task<int> 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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), "회수분은 항상 제외");
+    }
+
+    // ── 테스트 로컬 헬퍼 ──────────────────────────────────────────
+
+    private static Task<bool> ChatBanCheckerIsBanned(AppDbContext db, int memberID, string roomKey, DateTime now)
+        => Application.Helpers.ChatBanChecker.IsBannedAsync(db, memberID, roomKey, now, default);
+}

+ 1 - 1
Web.Api/Endpoints/Member/ItemsList.cs

@@ -10,7 +10,7 @@ internal sealed class ItemsList : IEndpoint
 {
     public void MapEndpoint(IEndpointRouteBuilder app)
     {
-        app.MapGet("api/members/me/items", async (
+        app.MapGet("api/member/me/items", async (
             bool? includeUsed,
             int? page,
             int? perPage,

+ 1 - 1
Web.Api/Endpoints/Member/ItemsUse.cs

@@ -10,7 +10,7 @@ internal sealed class ItemsUse : IEndpoint
 {
     public void MapEndpoint(IEndpointRouteBuilder app)
     {
-        app.MapPost("api/members/me/items/{id:int}/use", async (
+        app.MapPost("api/member/me/items/{id:int}/use", async (
             int id,
             ClaimsPrincipal user,
             ISender sender,