From 75a36e8a598fd09cdc2c4103dc7a779779c58eb5 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:15:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(user):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EC=97=90=20hasReviewableItem=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지 figma 04-order-list 명세 '픽업 완료 상태일 경우 리뷰 작성 버튼 노출'을 백엔드 카드 단위로 노출 가능하도록 hasReviewableItem 필드를 추가한다. 조건: status === PICKED_UP && (active 리뷰 미작성 item이 1건이라도 존재). - SDL MyOrderSummary에 hasReviewableItem: Boolean! 추가 - TS DTO 동기화 - OrderRepository.findReviewableOrderIds 메서드 신규 (단일 IN 쿼리, N+1 회피) - user-order.service에서 list 매핑 시 set 조회 후 hasReviewableItem 계산 - 회귀 테스트 6건 (미작성/active리뷰/soft-delete/CONFIRMED/CANCELED/혼합) --- .../order/repositories/order.repository.ts | 32 ++++ .../user/services/user-order.service.spec.ts | 142 ++++++++++++++++++ .../user/services/user-order.service.ts | 8 + .../user/types/user-order-output.type.ts | 1 + src/features/user/user-order.graphql | 2 + 5 files changed, 185 insertions(+) diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index d252967..81d9d43 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -142,6 +142,38 @@ export class OrderRepository { }); } + /** + * 주어진 orderId 중 PICKED_UP 상태이며 active 리뷰가 미작성인 OrderItem을 + * 1건 이상 가진 order의 ID 집합을 반환한다. + * + * 주의: list 매핑에서 order별 개별 쿼리(N+1) 회피용. 단일 IN 쿼리로 처리. + */ + async findReviewableOrderIds(args: { + accountId: bigint; + orderIds: bigint[]; + }): Promise> { + if (args.orderIds.length === 0) return new Set(); + + const rows = await this.prisma.orderItem.findMany({ + where: { + order_id: { in: args.orderIds }, + deleted_at: null, + order: { + account_id: args.accountId, + status: OrderStatus.PICKED_UP, + }, + OR: [ + { review: { is: null } }, + { review: { is: { deleted_at: { not: null } } } }, + ], + }, + select: { order_id: true }, + distinct: ['order_id'], + }); + + return new Set(rows.map((r) => r.order_id.toString())); + } + async findOrderDetailByAccount(args: { orderId: bigint; accountId: bigint }) { return this.prisma.order.findFirst({ where: { diff --git a/src/features/user/services/user-order.service.spec.ts b/src/features/user/services/user-order.service.spec.ts index 5029e14..7d86efa 100644 --- a/src/features/user/services/user-order.service.spec.ts +++ b/src/features/user/services/user-order.service.spec.ts @@ -171,6 +171,148 @@ describe('UserOrderService (real DB)', () => { service.listMyOrders(account.id, { limit: 51 }), ).rejects.toThrow(BadRequestException); }); + + // ─── hasReviewableItem ─── + describe('hasReviewableItem', () => { + async function createPickedUpOrderWithItem(accountId: bigint) { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: accountId, + status: 'PICKED_UP', + }); + const item = await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + return { store, product, order, item }; + } + + async function createReview(args: { + orderItemId: bigint; + accountId: bigint; + storeId: bigint; + productId: bigint; + deletedAt?: Date | null; + }) { + return prisma.review.create({ + data: { + order_item_id: args.orderItemId, + account_id: args.accountId, + store_id: args.storeId, + product_id: args.productId, + rating: 5, + content: '리뷰 더미 텍스트입니다. 만족합니다.', + deleted_at: args.deletedAt ?? null, + }, + }); + } + + it('PICKED_UP + 리뷰 미작성 item이 1건이면 true', async () => { + const account = await setupUser(); + await createPickedUpOrderWithItem(account.id); + + const result = await service.listMyOrders(account.id); + + expect(result.items).toHaveLength(1); + expect(result.items[0].hasReviewableItem).toBe(true); + }); + + it('PICKED_UP + 모든 item에 active 리뷰가 있으면 false', async () => { + const account = await setupUser(); + const { item, store, product } = await createPickedUpOrderWithItem( + account.id, + ); + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('PICKED_UP + 모든 item의 리뷰가 soft-delete면 true', async () => { + const account = await setupUser(); + const { item, store, product } = await createPickedUpOrderWithItem( + account.id, + ); + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + deletedAt: new Date(), + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(true); + }); + + it('CONFIRMED 등 비-PICKED_UP 상태는 false', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'CONFIRMED', + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].status).toBe('CONFIRMED'); + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('CANCELED 상태는 false', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'CANCELED', + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].status).toBe('CANCELED'); + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('PICKED_UP + 일부 item에만 리뷰 미작성이면 true (혼합 케이스)', async () => { + const account = await setupUser(); + const { item, store, product, order } = + await createPickedUpOrderWithItem(account.id); + // item1에는 리뷰가 있고, item2에는 없음 + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + }); + const product2 = await createProduct(prisma, { store_id: store.id }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product2.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(true); + }); + }); }); // ─── getMyOrder ─── diff --git a/src/features/user/services/user-order.service.ts b/src/features/user/services/user-order.service.ts index dc7cf25..594165c 100644 --- a/src/features/user/services/user-order.service.ts +++ b/src/features/user/services/user-order.service.ts @@ -51,6 +51,13 @@ export class UserOrderService { const hasMore = orders.length > limit; const sliced = hasMore ? orders.slice(0, limit) : orders; + // N+1 회피: PICKED_UP + 미작성 리뷰가 있는 order id 집합을 단일 IN 쿼리로 조회 + const reviewableOrderIds = + await this.orderRepository.findReviewableOrderIds({ + accountId, + orderIds: sliced.map((o) => o.id), + }); + return { items: sliced.map((order) => { const firstItem = order.items[0]; @@ -69,6 +76,7 @@ export class UserOrderService { additionalItemCount: Math.max(0, itemCount - 1), totalPrice: order.total_price, storeName: firstItem?.store?.store_name ?? '매장 정보 없음', + hasReviewableItem: reviewableOrderIds.has(order.id.toString()), }; }), totalCount, diff --git a/src/features/user/types/user-order-output.type.ts b/src/features/user/types/user-order-output.type.ts index 4013c1f..76ee5db 100644 --- a/src/features/user/types/user-order-output.type.ts +++ b/src/features/user/types/user-order-output.type.ts @@ -11,6 +11,7 @@ export interface MyOrderSummary { additionalItemCount: number; totalPrice: number; storeName: string; + hasReviewableItem: boolean; } export interface MyOrderConnection { diff --git a/src/features/user/user-order.graphql b/src/features/user/user-order.graphql index 0ed313a..41025f7 100644 --- a/src/features/user/user-order.graphql +++ b/src/features/user/user-order.graphql @@ -37,6 +37,8 @@ type MyOrderSummary { totalPrice: Int! """매장명""" storeName: String! + """주문 내 OrderItem 중 리뷰 작성 가능한 것이 1건이라도 있는지 (PICKED_UP + 활성 리뷰 미존재)""" + hasReviewableItem: Boolean! } """주문 상세""" From 0779ac9dbee355d4351437c88760a7eb1faa1194 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:22:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(user):=20listMyOrders=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=200=EA=B1=B4=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orderRepository.findReviewableOrderIds의 빈 배열 early return 분기 커버. codecov patch coverage가 100%가 되도록 보강. --- src/features/user/services/user-order.service.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/features/user/services/user-order.service.spec.ts b/src/features/user/services/user-order.service.spec.ts index 7d86efa..6cf9019 100644 --- a/src/features/user/services/user-order.service.spec.ts +++ b/src/features/user/services/user-order.service.spec.ts @@ -172,6 +172,16 @@ describe('UserOrderService (real DB)', () => { ).rejects.toThrow(BadRequestException); }); + it('주문이 0건이면 빈 connection을 반환한다', async () => { + const account = await setupUser(); + + const result = await service.listMyOrders(account.id); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + }); + // ─── hasReviewableItem ─── describe('hasReviewableItem', () => { async function createPickedUpOrderWithItem(accountId: bigint) {