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',