Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/features/order/repositories/order.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>> {
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: {
Expand Down
152 changes: 152 additions & 0 deletions src/features/user/services/user-order.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,158 @@ describe('UserOrderService (real DB)', () => {
service.listMyOrders(account.id, { limit: 51 }),
).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) {
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 ───
Expand Down
8 changes: 8 additions & 0 deletions src/features/user/services/user-order.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/features/user/types/user-order-output.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface MyOrderSummary {
additionalItemCount: number;
totalPrice: number;
storeName: string;
hasReviewableItem: boolean;
}

export interface MyOrderConnection {
Expand Down
2 changes: 2 additions & 0 deletions src/features/user/user-order.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type MyOrderSummary {
totalPrice: Int!
"""매장명"""
storeName: String!
"""주문 내 OrderItem 중 리뷰 작성 가능한 것이 1건이라도 있는지 (PICKED_UP + 활성 리뷰 미존재)"""
hasReviewableItem: Boolean!
}

"""주문 상세"""
Expand Down
Loading