From 41c1df9de18f9e68d148b50acda7a1c3e8dbda00 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 21:28:29 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(StoreImage=20soft-delete=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=C2=B7=EB=B9=88=EA=B0=92/0n=20ID=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4=C2=B7discountRate=20clamp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreImage를 SOFT_DELETE_MODELS에 등록해 직접 조회 시 deleted_at 자동 필터 적용 (Codex P2) - storeProducts/storeReviews input의 ID(storeId·categoryId·cursor)에 @IsNotEmpty 추가 (빈 문자열 차단) - discountRate를 0~100으로 clamp (음수 salePrice 방어) - accountId 0n falsy 분기를 !== undefined로 (storeReviews·storeDetail — account id 0의 좋아요/찜 누락 방지) - product-storefront service spec: 비활성/삭제 매장 케이스 분리 + soft-delete 검증 추가 CodeRabbit Functional + Codex P2 반영. CodeRabbit Major(spec을 mock으로 전환)는 testcontainers realDB 통합 컨벤션 유지로 미반영. --- .../dto/inputs/store-products.input.ts | 12 ++++++++- .../product-storefront-mappers.helper.spec.ts | 4 +++ .../product-storefront-mappers.helper.ts | 4 ++- .../product-storefront.service.spec.ts | 26 ++++++++++++++++++- .../store/dto/inputs/store-reviews.input.ts | 11 +++++++- .../store/services/store-detail.service.ts | 2 +- .../store/services/store-review.service.ts | 2 +- src/prisma/soft-delete.middleware.ts | 1 + 8 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/features/product/dto/inputs/store-products.input.ts b/src/features/product/dto/inputs/store-products.input.ts index ead9ac6..7dda606 100644 --- a/src/features/product/dto/inputs/store-products.input.ts +++ b/src/features/product/dto/inputs/store-products.input.ts @@ -1,11 +1,20 @@ -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; export class StoreProductsInput { @IsString() + @IsNotEmpty() storeId!: string; @IsOptional() @IsString() + @IsNotEmpty() categoryId?: string; @IsOptional() @@ -14,6 +23,7 @@ export class StoreProductsInput { @IsOptional() @IsString() + @IsNotEmpty() cursor?: string; @IsOptional() diff --git a/src/features/product/services/product-storefront-mappers.helper.spec.ts b/src/features/product/services/product-storefront-mappers.helper.spec.ts index d9aacc1..8f66648 100644 --- a/src/features/product/services/product-storefront-mappers.helper.spec.ts +++ b/src/features/product/services/product-storefront-mappers.helper.spec.ts @@ -40,6 +40,10 @@ describe('calcDiscountRate', () => { it('정가가 0 이하이면 0', () => { expect(calcDiscountRate(0, 0)).toBe(0); }); + + it('salePrice가 음수 등 비정상이면 0~100으로 clamp한다', () => { + expect(calcDiscountRate(40000, -10000)).toBe(100); + }); }); describe('toStoreProduct', () => { diff --git a/src/features/product/services/product-storefront-mappers.helper.ts b/src/features/product/services/product-storefront-mappers.helper.ts index 5efc70d..2589b0d 100644 --- a/src/features/product/services/product-storefront-mappers.helper.ts +++ b/src/features/product/services/product-storefront-mappers.helper.ts @@ -15,7 +15,9 @@ export function calcDiscountRate( if (salePrice === null || regularPrice <= 0 || salePrice >= regularPrice) { return 0; } - return Math.round((1 - salePrice / regularPrice) * 100); + // salePrice가 음수 등 비정상 값이어도 공개 계약(0~100)을 지키도록 clamp + const rate = Math.round((1 - salePrice / regularPrice) * 100); + return Math.min(100, Math.max(0, rate)); } export function toStoreProduct(row: StoreProductRow): StoreProduct { diff --git a/src/features/product/services/product-storefront.service.spec.ts b/src/features/product/services/product-storefront.service.spec.ts index bc023e0..72a899d 100644 --- a/src/features/product/services/product-storefront.service.spec.ts +++ b/src/features/product/services/product-storefront.service.spec.ts @@ -334,7 +334,7 @@ describe('ProductStorefrontService (real DB)', () => { expect(result).toEqual([]); }); - it('비활성/삭제 매장의 카테고리는 노출하지 않는다', async () => { + it('비활성 매장의 카테고리는 노출하지 않는다', async () => { const store = await createStore(prisma, { is_active: false }); const product = await createProduct(prisma, { store_id: store.id }); const cat = await prisma.category.create({ @@ -353,5 +353,29 @@ describe('ProductStorefrontService (real DB)', () => { expect(result).toEqual([]); }); + + it('soft-delete된 매장의 카테고리는 노출하지 않는다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const cat = await prisma.category.create({ + data: { + name: '돌잔치', + category_type: 'EVENT', + sort_order: 0, + is_active: true, + }, + }); + await prisma.productCategory.create({ + data: { product_id: product.id, category_id: cat.id }, + }); + await prisma.store.update({ + where: { id: store.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.storeProductCategories(store.id.toString()); + + expect(result).toEqual([]); + }); }); }); diff --git a/src/features/store/dto/inputs/store-reviews.input.ts b/src/features/store/dto/inputs/store-reviews.input.ts index 81d2e49..8216e45 100644 --- a/src/features/store/dto/inputs/store-reviews.input.ts +++ b/src/features/store/dto/inputs/store-reviews.input.ts @@ -1,11 +1,20 @@ -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; export class StoreReviewsInput { @IsString() + @IsNotEmpty() storeId!: string; @IsOptional() @IsString() + @IsNotEmpty() cursor?: string; @IsOptional() diff --git a/src/features/store/services/store-detail.service.ts b/src/features/store/services/store-detail.service.ts index e9c6830..aaa9073 100644 --- a/src/features/store/services/store-detail.service.ts +++ b/src/features/store/services/store-detail.service.ts @@ -30,7 +30,7 @@ export class StoreDetailService { const [reviewStats, wishlistedIds] = await Promise.all([ this.repo.aggregateReviewStats([storeId]), - accountId + accountId !== undefined ? this.wishlistRepo.findWishlistedStoreIds({ accountId, storeIds: [storeId], diff --git a/src/features/store/services/store-review.service.ts b/src/features/store/services/store-review.service.ts index 874f1ca..d1f45c3 100644 --- a/src/features/store/services/store-review.service.ts +++ b/src/features/store/services/store-review.service.ts @@ -37,7 +37,7 @@ export class StoreReviewService { const [likeCounts, likedIds] = await Promise.all([ this.repo.aggregateLikeCounts(reviewIds), - accountId + accountId !== undefined ? this.repo.findLikedReviewIds({ reviewIds, accountId }) : Promise.resolve(new Set()), ]); diff --git a/src/prisma/soft-delete.middleware.ts b/src/prisma/soft-delete.middleware.ts index fdb4542..9d25cda 100644 --- a/src/prisma/soft-delete.middleware.ts +++ b/src/prisma/soft-delete.middleware.ts @@ -10,6 +10,7 @@ const SOFT_DELETE_MODELS = new Set([ 'Store', 'StoreBusinessHour', 'StoreSpecialClosure', + 'StoreImage', 'Category', 'Tag', 'Product',