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..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); @@ -209,6 +247,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 +523,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 +659,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..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, }, }, }, @@ -143,11 +148,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 +215,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 +266,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 +495,7 @@ export class ProductRepository { }, }, option_items: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -515,6 +533,7 @@ export class ProductRepository { orderBy: { sort_order: 'asc' }, include: { option_items: { + where: activeWhere, orderBy: { sort_order: 'asc' }, }, }, @@ -662,6 +681,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 } : {}), }, }); }