From cd200581976536bad4e5eb85f72391bbceb0d2a3 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 23:35:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20soft-delete=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=A7=80=EC=A0=90=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?(=EC=85=80=EB=9F=AC=20=EC=A1=B0=ED=9A=8C=C2=B7=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=A7=91=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #207 전수 조사에서 발견된, 같은 파일 내 형제 필드는 가드가 있는데 빠져 있던 명백한 불일치 지점을 보강한다. - product.repository 상품 조회 3경로(listProductsByStore·findProductById· findProductByIdIncludingInactive): product_categories/product_tags에 링크·대상 soft-delete 가드 추가. is_active는 셀러 화면에서 기존 지정을 계속 보여줘야 하므로 걸지 않는다(명세 외 정책 결정). - product.repository 셀러 옵션·템플릿 read 3곳(findOptionGroupById· listOptionGroupsByProduct·findCustomTemplateById): option_items/ text_tokens 가드 추가. - order.repository 셀러 주문 상세(findOrderDetailByStore): 유저측 상세와 동일하게 status_histories·items·option_items·custom_texts·free_edits· attachments 가드 추가. 셀러 목록·상세의 items.some에도 가드를 얹어 soft-delete 아이템만 남은 주문이 노출되지 않게 한다. - order.repository findReviewableOrderIds: 형제 메서드 (listReviewableOrderItems)와 동일하게 order 관계 필터에 가드 추가. - 테스트 팩토리: createOrder/createOrderItem에 deleted_at override 지원. 회귀 테스트 6건: 상품 상세 카테고리·태그 링크 제외(repository), 옵션 아이템·텍스트 토큰 제외(repository), 셀러 주문 상세 아이템·이력 제외, soft-delete 아이템만 있는 주문의 상세 NotFound·목록 제외(service), soft-delete 주문의 리뷰 가능 집계 제외(repository). 전체 validate green (192 suites / 1,655 tests). Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../repositories/order.repository.spec.ts | 23 +++++ .../order/repositories/order.repository.ts | 13 ++- .../repositories/product.repository.spec.ts | 84 +++++++++++++++++++ .../repositories/product.repository.ts | 15 ++++ .../services/seller-order.service.spec.ts | 38 +++++++++ src/test/factories/order.factory.ts | 4 + 6 files changed, 176 insertions(+), 1 deletion(-) diff --git a/src/features/order/repositories/order.repository.spec.ts b/src/features/order/repositories/order.repository.spec.ts index 2c9ff5e..4228e86 100644 --- a/src/features/order/repositories/order.repository.spec.ts +++ b/src/features/order/repositories/order.repository.spec.ts @@ -180,6 +180,29 @@ describe('OrderRepository (real DB)', () => { }); }); + describe('findReviewableOrderIds', () => { + it('soft-delete된 주문의 아이템은 리뷰 가능 집계에서 제외한다', async () => { + const buyer = await setupBuyer(); + const active = await createOrder(prisma, { + account_id: buyer.id, + status: 'PICKED_UP', + }); + await createOrderItem(prisma, { order_id: active.id }); + const deleted = await createOrder(prisma, { + account_id: buyer.id, + status: 'PICKED_UP', + deleted_at: new Date(), + }); + await createOrderItem(prisma, { order_id: deleted.id }); + + const ids = await repo.findReviewableOrderIds({ + accountId: buyer.id, + orderIds: [active.id, deleted.id], + }); + expect(ids).toEqual(new Set([active.id.toString()])); + }); + }); + describe('findOrderDetailByAccount', () => { it('본인 주문이면 상세 반환 (status_histories 포함)', async () => { const buyer = await setupBuyer(); diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index 66b7007..ccd0c41 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -368,6 +368,9 @@ export class OrderRepository { order: { account_id: args.accountId, status: OrderStatus.PICKED_UP, + // 삭제된 주문의 아이템이 리뷰 가능으로 집계되지 않게 명시 + // (listReviewableOrderItems와 동일 가드) + ...activeWhere, }, OR: [ { review: { is: null } }, @@ -557,6 +560,7 @@ export class OrderRepository { items: { some: { store_id: args.storeId, + ...activeWhere, }, }, }, @@ -572,11 +576,14 @@ export class OrderRepository { items: { some: { store_id: args.storeId, + ...activeWhere, }, }, }, + // 유저측 상세(findOrderDetailByUser)와 동일하게 soft-delete 자식을 가드한다 include: { status_histories: { + where: activeWhere, orderBy: { changed_at: 'desc', }, @@ -584,16 +591,20 @@ export class OrderRepository { items: { where: { store_id: args.storeId, + ...activeWhere, }, include: { - option_items: true, + option_items: { where: activeWhere }, custom_texts: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, free_edits: { + where: activeWhere, orderBy: { sort_order: 'asc' }, include: { attachments: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, diff --git a/src/features/product/repositories/product.repository.spec.ts b/src/features/product/repositories/product.repository.spec.ts index f0a34b9..67f1d32 100644 --- a/src/features/product/repositories/product.repository.spec.ts +++ b/src/features/product/repositories/product.repository.spec.ts @@ -209,6 +209,50 @@ describe('ProductRepository (real DB)', () => { }); expect(result).toBeNull(); }); + + it('soft-delete된 카테고리·태그 연결과 삭제된 대상은 제외한다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const liveCategory = await createCategory('생일'); + const linkDeletedCategory = await createCategory('링크 삭제'); + const deletedCategory = await createCategory('대상 삭제'); + await prisma.productCategory.createMany({ + data: [ + { product_id: product.id, category_id: liveCategory.id }, + { + product_id: product.id, + category_id: linkDeletedCategory.id, + deleted_at: new Date(), + }, + { product_id: product.id, category_id: deletedCategory.id }, + ], + }); + await prisma.category.update({ + where: { id: deletedCategory.id }, + data: { deleted_at: new Date() }, + }); + const liveTag = await createTag('레터링'); + const linkDeletedTag = await createTag('링크 삭제 태그'); + await prisma.productTag.createMany({ + data: [ + { product_id: product.id, tag_id: liveTag.id }, + { + product_id: product.id, + tag_id: linkDeletedTag.id, + deleted_at: new Date(), + }, + ], + }); + + const result = await repo.findProductById({ + productId: product.id, + storeId: store.id, + }); + expect(result?.product_categories.map((c) => c.category.name)).toEqual([ + '생일', + ]); + expect(result?.product_tags.map((t) => t.tag.name)).toEqual(['레터링']); + }); }); describe('findProductByIdIncludingInactive', () => { @@ -441,6 +485,26 @@ describe('ProductRepository (real DB)', () => { expect(result.option_items).toHaveLength(1); }); + it('findOptionGroupById·listOptionGroupsByProduct는 soft-delete된 옵션 아이템을 제외한다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const group = await createOptionGroup(product.id); + const live = await createOptionItem(group.id); + await prisma.productOptionItem.create({ + data: { + option_group_id: group.id, + title: '삭제된 항목', + deleted_at: new Date(), + }, + }); + + const found = await repo.findOptionGroupById(group.id); + expect(found?.option_items.map((i) => i.id)).toEqual([live.id]); + + const rows = await repo.listOptionGroupsByProduct(product.id); + expect(rows[0].option_items.map((i) => i.id)).toEqual([live.id]); + }); + it('softDeleteOptionGroup: deleted_at + is_active:false', async () => { const store = await createStore(prisma); const product = await createProduct(prisma, { store_id: store.id }); @@ -557,6 +621,26 @@ describe('ProductRepository (real DB)', () => { expect(second.is_active).toBe(false); }); + it('findCustomTemplateById는 soft-delete된 텍스트 토큰을 제외한다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const tpl = await createTemplate(product.id); + const live = await prisma.productCustomTextToken.create({ + data: { template_id: tpl.id, token_key: 'live', default_text: '문구' }, + }); + await prisma.productCustomTextToken.create({ + data: { + template_id: tpl.id, + token_key: 'deleted', + default_text: '삭제', + deleted_at: new Date(), + }, + }); + + const found = await repo.findCustomTemplateById(tpl.id); + expect(found?.text_tokens.map((t) => t.id)).toEqual([live.id]); + }); + it('findCustomTemplateById + setCustomTemplateActive', async () => { const store = await createStore(prisma); const product = await createProduct(prisma, { store_id: store.id }); diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 2828079..04ab279 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -143,11 +143,15 @@ export class ProductRepository { orderBy: { sort_order: 'asc' }, }, product_categories: { + // 링크·대상 카테고리의 soft-delete 가드. is_active는 셀러 화면에서 + // 기존 지정을 계속 보여줘야 하므로 걸지 않는다. + where: { ...activeWhere, category: activeWhere }, include: { category: true, }, }, product_tags: { + where: { ...activeWhere, tag: activeWhere }, include: { tag: true, }, @@ -206,11 +210,15 @@ export class ProductRepository { orderBy: { sort_order: 'asc' }, }, product_categories: { + // 링크·대상 카테고리의 soft-delete 가드. is_active는 셀러 화면에서 + // 기존 지정을 계속 보여줘야 하므로 걸지 않는다. + where: { ...activeWhere, category: activeWhere }, include: { category: true, }, }, product_tags: { + where: { ...activeWhere, tag: activeWhere }, include: { tag: true, }, @@ -253,11 +261,15 @@ export class ProductRepository { orderBy: { sort_order: 'asc' }, }, product_categories: { + // 링크·대상 카테고리의 soft-delete 가드. is_active는 셀러 화면에서 + // 기존 지정을 계속 보여줘야 하므로 걸지 않는다. + where: { ...activeWhere, category: activeWhere }, include: { category: true, }, }, product_tags: { + where: { ...activeWhere, tag: activeWhere }, include: { tag: true, }, @@ -478,6 +490,7 @@ export class ProductRepository { }, }, option_items: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -515,6 +528,7 @@ export class ProductRepository { orderBy: { sort_order: 'asc' }, include: { option_items: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -662,6 +676,7 @@ export class ProductRepository { }, }, text_tokens: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, diff --git a/src/features/seller/services/seller-order.service.spec.ts b/src/features/seller/services/seller-order.service.spec.ts index 173a7e8..e9305ef 100644 --- a/src/features/seller/services/seller-order.service.spec.ts +++ b/src/features/seller/services/seller-order.service.spec.ts @@ -158,6 +158,44 @@ describe('SellerOrderService (real DB)', () => { expect(result.statusHistories).toHaveLength(1); expect(result.statusHistories[0].toStatus).toBe('CONFIRMED'); }); + + it('soft-delete된 아이템·상태 이력은 상세에서 제외한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const order = await createStoreOrder(store.id, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: order.id, + store_id: store.id, + deleted_at: new Date(), + }); + await prisma.orderStatusHistory.create({ + data: { + order_id: order.id, + from_status: 'SUBMITTED', + to_status: 'CONFIRMED', + changed_at: new Date('2026-04-15T10:00:00Z'), + deleted_at: new Date(), + }, + }); + + const result = await service.sellerOrder(account.id, order.id); + expect(result.items).toHaveLength(1); + expect(result.statusHistories).toHaveLength(0); + }); + + it('soft-delete된 아이템만 있는 주문은 상세·목록 모두에서 제외한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + const order = await createStoreOrder(store.id); + await prisma.orderItem.updateMany({ + where: { order_id: order.id }, + data: { deleted_at: new Date() }, + }); + + await expect(service.sellerOrder(account.id, order.id)).rejects.toThrow( + NotFoundException, + ); + const list = await service.sellerOrderList(account.id); + expect(list.items).toHaveLength(0); + }); }); describe('sellerUpdateOrderStatus', () => { diff --git a/src/test/factories/order.factory.ts b/src/test/factories/order.factory.ts index 358ea23..902c606 100644 --- a/src/test/factories/order.factory.ts +++ b/src/test/factories/order.factory.ts @@ -19,6 +19,7 @@ export interface OrderOverrides { subtotal_price?: number; discount_price?: number; total_price?: number; + deleted_at?: Date; } export async function createOrder( @@ -42,6 +43,7 @@ export async function createOrder( subtotal_price: overrides.subtotal_price ?? 10000, discount_price: overrides.discount_price ?? 0, total_price: overrides.total_price ?? 10000, + ...(overrides.deleted_at ? { deleted_at: overrides.deleted_at } : {}), }, }); } @@ -55,6 +57,7 @@ export interface OrderItemOverrides { sale_price_snapshot?: number | null; quantity?: number; item_subtotal_price?: number; + deleted_at?: Date; } export async function createOrderItem( @@ -92,6 +95,7 @@ export async function createOrderItem( sale_price_snapshot: overrides.sale_price_snapshot ?? null, quantity: overrides.quantity ?? 1, item_subtotal_price: overrides.item_subtotal_price ?? 10000, + ...(overrides.deleted_at ? { deleted_at: overrides.deleted_at } : {}), }, }); } From da3725deee8f4b8675ae6c5edb061d5a1e96502d Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 23:44:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=85=80=EB=9F=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=95=84=ED=84=B0=20predicate=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=C2=B7=EB=8C=80=EC=83=81=20soft-delete=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 리뷰 반영(PR #222): listProductsByStore의 categoryId·search 태그 predicate가 include와 달리 무가드라, 삭제된 연결만 가진 상품이 필터에 걸린 채 연결은 숨겨지는 불일치가 있었다. include와 동일한 가드를 적용하고 회귀 테스트 1건(삭제된 연결·태그로는 목록 필터 미매칭)을 추가한다. Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../repositories/product.repository.spec.ts | 38 +++++++++++++++++++ .../repositories/product.repository.ts | 5 +++ 2 files changed, 43 insertions(+) diff --git a/src/features/product/repositories/product.repository.spec.ts b/src/features/product/repositories/product.repository.spec.ts index 67f1d32..0e069bf 100644 --- a/src/features/product/repositories/product.repository.spec.ts +++ b/src/features/product/repositories/product.repository.spec.ts @@ -64,6 +64,44 @@ describe('ProductRepository (real DB)', () => { // ─── Product list/fetch ── describe('listProductsByStore', () => { + it('삭제된 카테고리 연결·태그로는 목록 필터에 걸리지 않는다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { + store_id: store.id, + name: '무관한 이름', + }); + const category = await createCategory('생일'); + await prisma.productCategory.create({ + data: { + product_id: product.id, + category_id: category.id, + deleted_at: new Date(), + }, + }); + const tag = await createTag('레터링'); + await prisma.productTag.create({ + data: { + product_id: product.id, + tag_id: tag.id, + deleted_at: new Date(), + }, + }); + + const byCategory = await repo.listProductsByStore({ + storeId: store.id, + limit: 10, + categoryId: category.id, + }); + expect(byCategory).toHaveLength(0); + + const bySearch = await repo.listProductsByStore({ + storeId: store.id, + limit: 10, + search: '레터링', + }); + expect(bySearch).toHaveLength(0); + }); + it('store_id 필터 + cursor 페이지네이션', async () => { const storeA = await createStore(prisma); const storeB = await createStore(prisma); diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 04ab279..ca25f85 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -112,9 +112,12 @@ export class ProductRepository { ...(args.isActive !== undefined ? { is_active: args.isActive } : {}), ...(args.categoryId ? { + // include의 링크·대상 가드와 동일 — 삭제된 연결이 필터에 걸리지 않게 한다 product_categories: { some: { category_id: args.categoryId, + ...activeWhere, + category: activeWhere, }, }, } @@ -126,8 +129,10 @@ export class ProductRepository { { product_tags: { some: { + ...activeWhere, tag: { name: { contains: args.search }, + ...activeWhere, }, }, },