From 6ad44459ab7534afe539e003134a0d5ce7e902ba Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 15 Oct 2026 20:57:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EC=9D=B4=20=EB=A6=AC=EB=B7=B0=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20FK=EB=A1=9C=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=8D=98?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 증상: 로컬에서 시드 리뷰에 댓글이 1건이라도 있으면 yarn prisma:seed 재실행이 resetSeedScope의 review.deleteMany에서 FK 위반으로 실패. 시드 사용자가 시드 밖 리뷰에 댓글을 달아도 계정 삭제가 막힘 - 원인: review_comment의 review_id·account_id FK가 둘 다 RESTRICT인데 resetSeedScope 정리 대상에 review_comment가 없음(review_report의 댓글 FK는 SET NULL이라 해당 없음) - 변경: 주문 아이템 기준 리뷰 삭제 앞에 그 리뷰의 댓글 삭제, 사용자 기준 리뷰 삭제 앞에 시드 사용자가 쓴 댓글과 시드 사용자 리뷰의 댓글 삭제 - 회귀 테스트 3건(src/test/seed-reset-scope.spec.ts, 실 시드 함수로 리뷰까지 채운 뒤 정리): 시드 리뷰에 외부 계정 댓글 → 정리 후 재시드 / 시드 사용자가 외부 리뷰에 단 댓글 → 댓글만 삭제·리뷰 유지 / 시드 주문 밖 시드 사용자 리뷰의 댓글 삭제 - 반증: 수정 역적용 시 3건 모두 FK 위반(review_id·account_id·review_id)으로 실패, 재적용 시 3건 통과 --- prisma/seed/idempotent.ts | 13 ++++ src/test/seed-reset-scope.spec.ts | 102 ++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/test/seed-reset-scope.spec.ts diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts index f4eb6b2e..6d606846 100644 --- a/prisma/seed/idempotent.ts +++ b/prisma/seed/idempotent.ts @@ -98,6 +98,10 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { await prisma.reviewMedia.deleteMany({ where: { review: { order_item_id: { in: orderItemIds } } }, }); + // 댓글은 작성자가 시드 밖 계정이어도 리뷰 FK(RESTRICT)에 걸린다 + await prisma.reviewComment.deleteMany({ + where: { review: { order_item_id: { in: orderItemIds } } }, + }); await prisma.review.deleteMany({ where: { order_item_id: { in: orderItemIds } }, }); @@ -118,6 +122,15 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { await prisma.reviewMedia.deleteMany({ where: { review: { account_id: { in: userIds } } }, }); + // 시드 유저 리뷰의 댓글(리뷰 FK)과 시드 유저가 남의 리뷰에 단 댓글(계정 FK) — 둘 다 RESTRICT + await prisma.reviewComment.deleteMany({ + where: { + OR: [ + { account_id: { in: userIds } }, + { review: { account_id: { in: userIds } } }, + ], + }, + }); await prisma.review.deleteMany({ where: { account_id: { in: userIds } } }); // 찜 diff --git a/src/test/seed-reset-scope.spec.ts b/src/test/seed-reset-scope.spec.ts new file mode 100644 index 00000000..f168b0b9 --- /dev/null +++ b/src/test/seed-reset-scope.spec.ts @@ -0,0 +1,102 @@ +import { seedCategories } from '../../prisma/seed/categories'; +import { resetSeedScope } from '../../prisma/seed/idempotent'; +import { seedOrders } from '../../prisma/seed/orders'; +import { seedRegions } from '../../prisma/seed/regions'; +import { seedReviews } from '../../prisma/seed/reviews'; +import { seedStores } from '../../prisma/seed/stores'; +import { seedUsers, type SeededUser } from '../../prisma/seed/users'; + +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createOrderItem, createReview } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +// 로컬에서 시드 데이터에 붙여 둔 행이 있어도 재시드(정리 → 재삽입)가 FK에 막히면 안 된다 +describe('resetSeedScope (real DB)', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + ({ prisma } = await createTestingModuleWithRealDb({ providers: [] })); + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + /** 시드 main과 같은 순서로 리뷰까지 채운다 */ + async function seedThroughReviews(): Promise { + const users = await seedUsers(prisma); + await seedRegions(prisma); + const categories = await seedCategories(prisma); + const stores = await seedStores(prisma, { categories }); + const orders = await seedOrders(prisma, { users, stores }); + await seedReviews(prisma, { users, stores, orders }); + return users; + } + + function writeComment(reviewId: bigint, accountId: bigint) { + return prisma.reviewComment.create({ + data: { review_id: reviewId, account_id: accountId, content: '댓글' }, + }); + } + + it('시드 리뷰에 다른 계정이 단 댓글이 있어도 정리하고 다시 시드한다', async () => { + const users = await seedThroughReviews(); + const seedReview = await prisma.review.findFirstOrThrow({ + where: { account_id: { in: users.map((u) => u.id) } }, + }); + const outsider = await createAccount(prisma, { account_type: 'USER' }); + const comment = await writeComment(seedReview.id, outsider.id); + + await resetSeedScope(prisma); + await seedThroughReviews(); + + expect( + await prisma.reviewComment.findUnique({ where: { id: comment.id } }), + ).toBeNull(); + expect( + await prisma.account.findUnique({ where: { id: outsider.id } }), + ).not.toBeNull(); + }); + + it('시드 사용자가 시드 밖 리뷰에 단 댓글은 지우고 그 리뷰는 남긴다', async () => { + const [seedUser] = await seedThroughReviews(); + const outsideReview = await createReview(prisma); + const comment = await writeComment(outsideReview.id, seedUser.id); + + await resetSeedScope(prisma); + + expect( + await prisma.reviewComment.findUnique({ where: { id: comment.id } }), + ).toBeNull(); + expect( + await prisma.review.findUnique({ where: { id: outsideReview.id } }), + ).not.toBeNull(); + }); + + it('시드 주문 밖에 있는 시드 사용자 리뷰의 댓글도 지운다', async () => { + const [seedUser] = await seedThroughReviews(); + const outsideItem = await createOrderItem(prisma); + const review = await createReview(prisma, { + order_item_id: outsideItem.id, + account_id: seedUser.id, + }); + const outsider = await createAccount(prisma, { account_type: 'USER' }); + const comment = await writeComment(review.id, outsider.id); + + await resetSeedScope(prisma); + + expect( + await prisma.review.findUnique({ where: { id: review.id } }), + ).toBeNull(); + expect( + await prisma.reviewComment.findUnique({ where: { id: comment.id } }), + ).toBeNull(); + }); +}); From f83757cd6d0647c7f6a14321b27fd57a1b45f76e Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 15 Oct 2026 21:12:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=8C=93=EA=B8=80=20=EC=8B=A0=EA=B3=A0=EB=8F=84=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=93=9C=20=EB=95=8C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리뷰 #534 지적 반영: 시드 리뷰의 댓글을 지우면 그 댓글 신고가 SET NULL로 대상 없는 PENDING이 됨. 신고 목록은 targetedReportWhere로 숨기지만 대시보드 대기 신고 수(countPendingReviewReports)는 계속 셈 - 0) 신고 정리에 시드 리뷰(시드 사용자 작성·시드 매장) 댓글 대상 신고 조건 추가 - 회귀 테스트 1건(외부 댓글의 외부 신고). 반증: 수정 역적용 시 이 1건 실패, 적용 시 4건 통과 --- prisma/seed/idempotent.ts | 11 +++++++++++ src/test/seed-reset-scope.spec.ts | 25 ++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts index 6d606846..c7b729b1 100644 --- a/prisma/seed/idempotent.ts +++ b/prisma/seed/idempotent.ts @@ -51,6 +51,17 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { }, }, { review_comment: { account_id: { in: userIds } } }, + // 아래에서 지울 시드 리뷰의 댓글 신고 — 남기면 SET NULL로 대상 없는 PENDING이 된다 + { + review_comment: { + review: { + OR: [ + { account_id: { in: userIds } }, + { store_id: { in: storeIds } }, + ], + }, + }, + }, ], }, }); diff --git a/src/test/seed-reset-scope.spec.ts b/src/test/seed-reset-scope.spec.ts index f168b0b9..e025f9a6 100644 --- a/src/test/seed-reset-scope.spec.ts +++ b/src/test/seed-reset-scope.spec.ts @@ -9,7 +9,12 @@ import { seedUsers, type SeededUser } from '../../prisma/seed/users'; import type { PrismaClient } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; -import { createAccount, createOrderItem, createReview } from '@/test/factories'; +import { + createAccount, + createOrderItem, + createReview, + createReviewReport, +} from '@/test/factories'; import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; // 로컬에서 시드 데이터에 붙여 둔 행이 있어도 재시드(정리 → 재삽입)가 FK에 막히면 안 된다 @@ -65,6 +70,24 @@ describe('resetSeedScope (real DB)', () => { ).not.toBeNull(); }); + it('시드 리뷰에 달린 외부 댓글의 외부 신고도 지워 대상 없는 신고가 남지 않는다', async () => { + const users = await seedThroughReviews(); + const seedReview = await prisma.review.findFirstOrThrow({ + where: { account_id: { in: users.map((u) => u.id) } }, + }); + const outsider = await createAccount(prisma, { account_type: 'USER' }); + const comment = await writeComment(seedReview.id, outsider.id); + const report = await createReviewReport(prisma, { + review_comment_id: comment.id, + }); + + await resetSeedScope(prisma); + + expect( + await prisma.reviewReport.findUnique({ where: { id: report.id } }), + ).toBeNull(); + }); + it('시드 사용자가 시드 밖 리뷰에 단 댓글은 지우고 그 리뷰는 남긴다', async () => { const [seedUser] = await seedThroughReviews(); const outsideReview = await createReview(prisma); From 80ae5a21c0503bc340c40f3fedb03e9373f8d6aa Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 15 Oct 2026 21:33:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20=EB=B0=96=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=EC=9D=98=20=EC=8B=9C=EB=93=9C=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EC=A3=BC=EB=AC=B8=EC=9D=B4=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EB=A7=89=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #535: 시드 밖 계정(로컬 구매자 테스트 계정 등)이 시드 매장 상품을 주문·리뷰하면 order_item·review의 product_id·store_id FK(RESTRICT) 때문에 재시드가 시드 상품 삭제에서 실패 - resetSeedScope 주문 정리 대상을 '시드 사용자 주문 + 시드 매장 품목이 든 주문'으로 넓힘. 주문은 상품 하나로 만들어져 매장도 하나라 시드 밖 매장 품목은 함께 지워지지 않음 - 이 주문·품목·리뷰를 가리키는 알림도 함께 삭제 — SET NULL로 대상 없는 알림이 남지 않게(#534 신고 건과 같은 축) - 회귀 테스트 2건(시드 상품 주문·리뷰 뒤 재시드, 시드 밖 매장 주문 보존). 반증: 수정 역적용 시 FK(product_id) 실패, 알림 정리만 빼면 알림 잔존으로 실패, 조회 조건을 전체로 넓히면 보존 테스트 실패 --- prisma/seed/idempotent.ts | 23 +++++++++-- src/test/seed-reset-scope.spec.ts | 68 ++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts index c7b729b1..8c07bb2f 100644 --- a/prisma/seed/idempotent.ts +++ b/prisma/seed/idempotent.ts @@ -70,17 +70,34 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { // 1) 유저 종속 (주문, 리뷰, 찜, 최근본, 알림, 드래프트, 검색기록, 카트, 인증세션) if (userIds.length > 0) { // 주문 종속들 → 주문 본체 (FK depth가 깊으므로 안에서 다시 처리) - const userOrders = await prisma.order.findMany({ - where: { account_id: { in: userIds } }, + // 시드 밖 계정이 시드 매장 상품으로 넣은 주문도 지운다 — 품목·리뷰가 시드 상품·매장 FK(RESTRICT)에 걸린다. + // 주문은 상품 하나로 만들어져 매장도 하나라, 시드 밖 매장 품목이 함께 지워지지 않는다 + const seedOrders = await prisma.order.findMany({ + where: { + OR: [ + { account_id: { in: userIds } }, + { items: { some: { store_id: { in: storeIds } } } }, + ], + }, select: { id: true }, }); - const orderIds = userOrders.map((o) => o.id); + const orderIds = seedOrders.map((o) => o.id); if (orderIds.length > 0) { const orderItems = await prisma.orderItem.findMany({ where: { order_id: { in: orderIds } }, select: { id: true }, }); const orderItemIds = orderItems.map((i) => i.id); + // 시드 밖 계정이 받은 이 주문·리뷰 알림 — 남기면 SET NULL로 대상 없는 알림이 된다 + await prisma.notification.deleteMany({ + where: { + OR: [ + { order_id: { in: orderIds } }, + { order_item_id: { in: orderItemIds } }, + { review: { order_item_id: { in: orderItemIds } } }, + ], + }, + }); if (orderItemIds.length > 0) { // OrderItemCustomFreeEdit -> attachments -> deletes const freeEdits = await prisma.orderItemCustomFreeEdit.findMany({ diff --git a/src/test/seed-reset-scope.spec.ts b/src/test/seed-reset-scope.spec.ts index e025f9a6..c1da5352 100644 --- a/src/test/seed-reset-scope.spec.ts +++ b/src/test/seed-reset-scope.spec.ts @@ -1,5 +1,8 @@ import { seedCategories } from '../../prisma/seed/categories'; -import { resetSeedScope } from '../../prisma/seed/idempotent'; +import { + SEED_STORE_NAME_PREFIX, + resetSeedScope, +} from '../../prisma/seed/idempotent'; import { seedOrders } from '../../prisma/seed/orders'; import { seedRegions } from '../../prisma/seed/regions'; import { seedReviews } from '../../prisma/seed/reviews'; @@ -11,6 +14,8 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, + createNotification, + createOrder, createOrderItem, createReview, createReviewReport, @@ -51,6 +56,67 @@ describe('resetSeedScope (real DB)', () => { }); } + /** 시드 밖 계정의 주문 1건(품목 1개)을 만든다. productId를 안 주면 시드 밖 매장 상품으로 */ + async function outsiderOrder(productId?: bigint) { + const outsider = await createAccount(prisma, { account_type: 'USER' }); + const order = await createOrder(prisma, { account_id: outsider.id }); + const item = await createOrderItem(prisma, { + order_id: order.id, + ...(productId ? { product_id: productId } : {}), + }); + return { outsider, order, item }; + } + + it('시드 밖 계정이 시드 상품을 주문하고 리뷰를 남겨도 정리하고 다시 시드한다', async () => { + await seedThroughReviews(); + const seedProduct = await prisma.product.findFirstOrThrow({ + where: { store: { store_name: { startsWith: SEED_STORE_NAME_PREFIX } } }, + }); + const { outsider, order, item } = await outsiderOrder(seedProduct.id); + const review = await createReview(prisma, { order_item_id: item.id }); + const notification = await createNotification(prisma, { + account_id: outsider.id, + order_id: order.id, + order_item_id: item.id, + review_id: review.id, + }); + + await resetSeedScope(prisma); + await seedThroughReviews(); + + expect( + await prisma.order.findUnique({ where: { id: order.id } }), + ).toBeNull(); + expect( + await prisma.review.findUnique({ where: { id: review.id } }), + ).toBeNull(); + expect( + await prisma.notification.findUnique({ where: { id: notification.id } }), + ).toBeNull(); + expect( + await prisma.account.findUnique({ where: { id: outsider.id } }), + ).not.toBeNull(); + }); + + it('시드 밖 계정이 시드 밖 매장에 넣은 주문은 남긴다', async () => { + await seedThroughReviews(); + const { order, item } = await outsiderOrder(); + const notification = await createNotification(prisma, { + account_id: order.account_id, + order_id: order.id, + order_item_id: item.id, + }); + + await resetSeedScope(prisma); + + expect( + await prisma.order.findUnique({ where: { id: order.id } }), + ).not.toBeNull(); + expect( + await prisma.notification.findUnique({ where: { id: notification.id } }), + ).not.toBeNull(); + }); + it('시드 리뷰에 다른 계정이 단 댓글이 있어도 정리하고 다시 시드한다', async () => { const users = await seedThroughReviews(); const seedReview = await prisma.review.findFirstOrThrow({ From cf0be1451bf6da1ea78f26ad6009389592b914f3 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 15 Oct 2026 21:42:18 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=97=86=EC=9D=B4=20=EB=82=A8=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=20=EB=A7=A4=EC=9E=A5=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=EB=8F=84=20=EC=9E=AC=EC=8B=9C=EB=93=9C=20=EB=95=8C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리뷰 #536 지적 반영: 주문 정리가 userIds.length > 0 블록 안에 있어, 이전 재시드가 2) 상품 삭제에서 실패한 DB(시드 사용자는 1) 끝에서 이미 삭제, 시드 매장·막는 주문 잔존)는 다시 돌려도 복구되지 않음 - 주문 정리를 사용자 조건과 분리(userIds 또는 storeIds가 있으면 실행), 나머지 유저 종속은 기존 조건 유지 - 회귀 테스트 1건(시드 매장만 남은 상태 재현). 반증: 사용자 조건을 되살리면 FK(product_id)로 실패, 적용 시 7건 통과 --- prisma/seed/idempotent.ts | 8 ++++++-- src/test/seed-reset-scope.spec.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts index 8c07bb2f..464d8a9b 100644 --- a/prisma/seed/idempotent.ts +++ b/prisma/seed/idempotent.ts @@ -67,8 +67,9 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { }); } - // 1) 유저 종속 (주문, 리뷰, 찜, 최근본, 알림, 드래프트, 검색기록, 카트, 인증세션) - if (userIds.length > 0) { + // 1) 주문 — 사용자 조건과 분리한다. 재시드가 2) 상품 삭제에서 실패하면 시드 사용자는 이미 지워져 + // 시드 매장과 막는 주문만 남으므로, 그 상태에서 다시 돌려도 복구돼야 한다 + if (userIds.length > 0 || storeIds.length > 0) { // 주문 종속들 → 주문 본체 (FK depth가 깊으므로 안에서 다시 처리) // 시드 밖 계정이 시드 매장 상품으로 넣은 주문도 지운다 — 품목·리뷰가 시드 상품·매장 FK(RESTRICT)에 걸린다. // 주문은 상품 하나로 만들어져 매장도 하나라, 시드 밖 매장 품목이 함께 지워지지 않는다 @@ -142,7 +143,10 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { }); await prisma.order.deleteMany({ where: { id: { in: orderIds } } }); } + } + // 1-2) 유저 종속 (리뷰, 찜, 최근본, 알림, 드래프트, 검색기록, 카트, 인증세션) + if (userIds.length > 0) { // 리뷰: 주문 종속에서 이미 처리되었지만, 다른 리뷰가 있을 수 있음 → 사용자 기준으로도 한 번 더 await prisma.reviewLike.deleteMany({ where: { account_id: { in: userIds } }, diff --git a/src/test/seed-reset-scope.spec.ts b/src/test/seed-reset-scope.spec.ts index c1da5352..fa48cf97 100644 --- a/src/test/seed-reset-scope.spec.ts +++ b/src/test/seed-reset-scope.spec.ts @@ -67,6 +67,25 @@ describe('resetSeedScope (real DB)', () => { return { outsider, order, item }; } + it('시드 사용자 없이 시드 매장과 시드 밖 주문만 남은 상태(이전 재시드 실패)도 정리한다', async () => { + // 이전 재시드가 상품 삭제에서 실패하면 시드 사용자는 이미 지워져 이 상태가 남는다 + await seedRegions(prisma); + const categories = await seedCategories(prisma); + const { products } = await seedStores(prisma, { categories }); + const { order } = await outsiderOrder(products[0].id); + + await resetSeedScope(prisma); + + expect( + await prisma.order.findUnique({ where: { id: order.id } }), + ).toBeNull(); + expect( + await prisma.store.count({ + where: { store_name: { startsWith: SEED_STORE_NAME_PREFIX } }, + }), + ).toBe(0); + }); + it('시드 밖 계정이 시드 상품을 주문하고 리뷰를 남겨도 정리하고 다시 시드한다', async () => { await seedThroughReviews(); const seedProduct = await prisma.product.findFirstOrThrow({