From d8b503b7d53759187d3e03057cf3c3609b63eb57 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 24 May 2026 03:15:58 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(user):=20GraphQL=20Input=20DTO=2013?= =?UTF-8?q?=EC=A2=85=20+=20IsRatingValid=20validator=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A-2 검증 전략 P0-3 단계 3 (User Input 전수). - UserPaginationInput 베이스 + 6종 페이지네이션 DTO (Orders/Reviews/ Wishlist/Notifications/SearchHistories/RecentViewedProducts) - 프로필 DTO 4종 (CompleteOnboarding / UpdateMyProfile / UpdateMyProfileImage / CreateProfileImageUploadUrl). 닉네임 길이· 정규식, 전화번호 형식, 생년월일 범위 등 SDL 로 표현 불가한 룰 적용 - 리뷰 작성 DTO 3종 (WriteReview / WriteReviewMedia / CreateReviewMediaUploadUrl). 별점 0.5 단위 검증을 위해 IsRatingValid 커스텀 validator 추가 - 단위 테스트 동반 (validate() 결과 직접 검증) --- .../inputs/complete-onboarding.input.spec.ts | 75 ++++++++++++++++ .../dto/inputs/complete-onboarding.input.ts | 52 +++++++++++ ...ate-profile-image-upload-url.input.spec.ts | 29 +++++++ .../create-profile-image-upload-url.input.ts | 16 ++++ ...eate-review-media-upload-url.input.spec.ts | 40 +++++++++ .../create-review-media-upload-url.input.ts | 15 ++++ .../dto/inputs/my-notifications.input.spec.ts | 36 ++++++++ .../user/dto/inputs/my-notifications.input.ts | 9 ++ .../user/dto/inputs/my-orders.input.spec.ts | 56 ++++++++++++ .../user/dto/inputs/my-orders.input.ts | 11 +++ .../inputs/my-recent-viewed-products.input.ts | 3 + .../user/dto/inputs/my-reviews.input.ts | 3 + .../dto/inputs/my-search-histories.input.ts | 3 + .../user/dto/inputs/my-wishlist.input.ts | 3 + .../update-my-profile-image.input.spec.ts | 45 ++++++++++ .../inputs/update-my-profile-image.input.ts | 13 +++ .../inputs/update-my-profile.input.spec.ts | 41 +++++++++ .../dto/inputs/update-my-profile.input.ts | 61 +++++++++++++ .../dto/inputs/user-pagination.input.spec.ts | 55 ++++++++++++ .../user/dto/inputs/user-pagination.input.ts | 21 +++++ .../inputs/write-review-media.input.spec.ts | 51 +++++++++++ .../dto/inputs/write-review-media.input.ts | 19 ++++ .../dto/inputs/write-review.input.spec.ts | 86 +++++++++++++++++++ .../user/dto/inputs/write-review.input.ts | 37 ++++++++ .../dto/validators/rating.validator.spec.ts | 48 +++++++++++ .../user/dto/validators/rating.validator.ts | 33 +++++++ 26 files changed, 861 insertions(+) create mode 100644 src/features/user/dto/inputs/complete-onboarding.input.spec.ts create mode 100644 src/features/user/dto/inputs/complete-onboarding.input.ts create mode 100644 src/features/user/dto/inputs/create-profile-image-upload-url.input.spec.ts create mode 100644 src/features/user/dto/inputs/create-profile-image-upload-url.input.ts create mode 100644 src/features/user/dto/inputs/create-review-media-upload-url.input.spec.ts create mode 100644 src/features/user/dto/inputs/create-review-media-upload-url.input.ts create mode 100644 src/features/user/dto/inputs/my-notifications.input.spec.ts create mode 100644 src/features/user/dto/inputs/my-notifications.input.ts create mode 100644 src/features/user/dto/inputs/my-orders.input.spec.ts create mode 100644 src/features/user/dto/inputs/my-orders.input.ts create mode 100644 src/features/user/dto/inputs/my-recent-viewed-products.input.ts create mode 100644 src/features/user/dto/inputs/my-reviews.input.ts create mode 100644 src/features/user/dto/inputs/my-search-histories.input.ts create mode 100644 src/features/user/dto/inputs/my-wishlist.input.ts create mode 100644 src/features/user/dto/inputs/update-my-profile-image.input.spec.ts create mode 100644 src/features/user/dto/inputs/update-my-profile-image.input.ts create mode 100644 src/features/user/dto/inputs/update-my-profile.input.spec.ts create mode 100644 src/features/user/dto/inputs/update-my-profile.input.ts create mode 100644 src/features/user/dto/inputs/user-pagination.input.spec.ts create mode 100644 src/features/user/dto/inputs/user-pagination.input.ts create mode 100644 src/features/user/dto/inputs/write-review-media.input.spec.ts create mode 100644 src/features/user/dto/inputs/write-review-media.input.ts create mode 100644 src/features/user/dto/inputs/write-review.input.spec.ts create mode 100644 src/features/user/dto/inputs/write-review.input.ts create mode 100644 src/features/user/dto/validators/rating.validator.spec.ts create mode 100644 src/features/user/dto/validators/rating.validator.ts diff --git a/src/features/user/dto/inputs/complete-onboarding.input.spec.ts b/src/features/user/dto/inputs/complete-onboarding.input.spec.ts new file mode 100644 index 0000000..caae095 --- /dev/null +++ b/src/features/user/dto/inputs/complete-onboarding.input.spec.ts @@ -0,0 +1,75 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CompleteOnboardingInput } from '@/features/user/dto/inputs/complete-onboarding.input'; + +function build(plain: object): CompleteOnboardingInput { + return plainToInstance(CompleteOnboardingInput, plain); +} + +describe('CompleteOnboardingInput', () => { + it('필수 필드만 (nickname) 허용', async () => { + const dto = build({ nickname: 'hello1' }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('전체 필드 + Date birthDate 허용', async () => { + const dto = build({ + name: '홍길동', + nickname: 'gildong', + birthDate: new Date('1990-01-15'), + phoneNumber: '010-1234-5678', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('nickname 누락 거절', async () => { + const dto = build({}); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'nickname')).toBe(true); + }); + + it('nickname 길이 1자 거절', async () => { + const dto = build({ nickname: 'a' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('nickname'); + expect(errors[0].constraints).toHaveProperty('isLength'); + }); + + it('nickname 허용 외 문자 거절', async () => { + const dto = build({ nickname: 'hello!' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('nickname'); + expect(errors[0].constraints).toHaveProperty('matches'); + }); + + it('phoneNumber 형식 오류 거절', async () => { + const dto = build({ nickname: 'gildong', phoneNumber: '01012345678' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('phoneNumber'); + }); + + it('birthDate 1900 이전 거절', async () => { + const dto = build({ + nickname: 'gildong', + birthDate: new Date('1850-01-01'), + }); + const errors = await validate(dto); + expect(errors[0].property).toBe('birthDate'); + }); + + it('birthDate 가 Date 객체가 아니면 거절', async () => { + const dto = build({ nickname: 'gildong', birthDate: '1990-01-15' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('birthDate'); + }); + + it('name 공백만 입력 시 trim 후 빈 문자열로 변환되어 MinLength 미통과는 아니나 MaxLength 통과', async () => { + // name 은 @IsOptional + @MaxLength 만 있고 @MinLength 없음. + // trim 후 빈 문자열은 통과 (서비스에서 normalizeName 이 null 변환). + const dto = build({ nickname: 'gildong', name: ' ' }); + expect(await validate(dto)).toHaveLength(0); + }); +}); diff --git a/src/features/user/dto/inputs/complete-onboarding.input.ts b/src/features/user/dto/inputs/complete-onboarding.input.ts new file mode 100644 index 0000000..5adfb46 --- /dev/null +++ b/src/features/user/dto/inputs/complete-onboarding.input.ts @@ -0,0 +1,52 @@ +import type { TransformFnParams } from 'class-transformer'; +import { Transform } from 'class-transformer'; +import { + IsDate, + IsOptional, + IsString, + Length, + Matches, + MaxLength, + MinDate, +} from 'class-validator'; + +import { + MAX_NICKNAME_LENGTH, + MIN_BIRTH_DATE, + MIN_NICKNAME_LENGTH, + PHONE_FORMAT_EXAMPLE, + PHONE_REGEX, +} from '@/features/user/constants/user.constants'; + +const NICKNAME_REGEX = /^[A-Za-z0-9가-힣_]+$/; + +export class CompleteOnboardingInput { + @IsOptional() + @Transform(({ value }: TransformFnParams): unknown => + typeof value === 'string' ? value.trim() : (value as unknown), + ) + @IsString() + @MaxLength(50) + name?: string | null; + + @IsString() + @Length(MIN_NICKNAME_LENGTH, MAX_NICKNAME_LENGTH) + @Matches(NICKNAME_REGEX, { + message: 'Nickname contains invalid characters.', + }) + nickname!: string; + + @IsOptional() + @IsDate() + @MinDate(MIN_BIRTH_DATE, { + message: 'birthDate is too old (before 1900-01-01).', + }) + birthDate?: Date | null; + + @IsOptional() + @IsString() + @Matches(PHONE_REGEX, { + message: `Invalid phone number format. Expected ${PHONE_FORMAT_EXAMPLE}.`, + }) + phoneNumber?: string | null; +} diff --git a/src/features/user/dto/inputs/create-profile-image-upload-url.input.spec.ts b/src/features/user/dto/inputs/create-profile-image-upload-url.input.spec.ts new file mode 100644 index 0000000..955fe2a --- /dev/null +++ b/src/features/user/dto/inputs/create-profile-image-upload-url.input.spec.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateProfileImageUploadUrlInput } from '@/features/user/dto/inputs/create-profile-image-upload-url.input'; + +function build(plain: object): CreateProfileImageUploadUrlInput { + return plainToInstance(CreateProfileImageUploadUrlInput, plain); +} + +describe('CreateProfileImageUploadUrlInput', () => { + it('정상 입력 통과', async () => { + const dto = build({ contentType: 'image/jpeg', contentLength: 1024 }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('contentLength 음수 거절', async () => { + const dto = build({ contentType: 'image/jpeg', contentLength: -1 }); + const errors = await validate(dto); + expect(errors[0].property).toBe('contentLength'); + }); + + it('contentType 누락 거절', async () => { + const dto = build({ contentLength: 1024 }); + const errors = await validate(dto); + expect(errors[0].property).toBe('contentType'); + }); +}); diff --git a/src/features/user/dto/inputs/create-profile-image-upload-url.input.ts b/src/features/user/dto/inputs/create-profile-image-upload-url.input.ts new file mode 100644 index 0000000..9bd8c31 --- /dev/null +++ b/src/features/user/dto/inputs/create-profile-image-upload-url.input.ts @@ -0,0 +1,16 @@ +import { IsInt, IsString, Min } from 'class-validator'; + +/** + * 프로필 이미지 업로드용 Presigned URL 발급 입력. + * + * contentType / contentLength 의 화이트리스트·상한 검증은 S3Service 의 + * createUploadUrl 이 담당. 여기서는 형식만 보장한다. + */ +export class CreateProfileImageUploadUrlInput { + @IsString() + contentType!: string; + + @IsInt() + @Min(1) + contentLength!: number; +} diff --git a/src/features/user/dto/inputs/create-review-media-upload-url.input.spec.ts b/src/features/user/dto/inputs/create-review-media-upload-url.input.spec.ts new file mode 100644 index 0000000..31d1255 --- /dev/null +++ b/src/features/user/dto/inputs/create-review-media-upload-url.input.spec.ts @@ -0,0 +1,40 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateReviewMediaUploadUrlInput } from '@/features/user/dto/inputs/create-review-media-upload-url.input'; + +function build(plain: object): CreateReviewMediaUploadUrlInput { + return plainToInstance(CreateReviewMediaUploadUrlInput, plain); +} + +describe('CreateReviewMediaUploadUrlInput', () => { + it('정상 입력 통과', async () => { + const dto = build({ + mediaType: 'IMAGE', + contentType: 'image/jpeg', + contentLength: 1024, + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('contentLength 0 거절', async () => { + const dto = build({ + mediaType: 'IMAGE', + contentType: 'image/jpeg', + contentLength: 0, + }); + const errors = await validate(dto); + expect(errors[0].property).toBe('contentLength'); + }); + + it('mediaType 누락 거절', async () => { + const dto = build({ + contentType: 'image/jpeg', + contentLength: 1024, + }); + const errors = await validate(dto); + expect(errors[0].property).toBe('mediaType'); + }); +}); diff --git a/src/features/user/dto/inputs/create-review-media-upload-url.input.ts b/src/features/user/dto/inputs/create-review-media-upload-url.input.ts new file mode 100644 index 0000000..fa25846 --- /dev/null +++ b/src/features/user/dto/inputs/create-review-media-upload-url.input.ts @@ -0,0 +1,15 @@ +import { IsIn, IsInt, IsString, Min } from 'class-validator'; + +import type { ReviewMediaTypeInput } from '@/features/user/dto/inputs/write-review-media.input'; + +export class CreateReviewMediaUploadUrlInput { + @IsIn(['IMAGE', 'VIDEO']) + mediaType!: ReviewMediaTypeInput; + + @IsString() + contentType!: string; + + @IsInt() + @Min(1) + contentLength!: number; +} diff --git a/src/features/user/dto/inputs/my-notifications.input.spec.ts b/src/features/user/dto/inputs/my-notifications.input.spec.ts new file mode 100644 index 0000000..25cb284 --- /dev/null +++ b/src/features/user/dto/inputs/my-notifications.input.spec.ts @@ -0,0 +1,36 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MyNotificationsInput } from '@/features/user/dto/inputs/my-notifications.input'; + +function build(plain: object): MyNotificationsInput { + return plainToInstance(MyNotificationsInput, plain); +} + +describe('MyNotificationsInput', () => { + it('unreadOnly true 허용', async () => { + const dto = build({ unreadOnly: true, offset: 0, limit: 20 }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('unreadOnly 누락 허용', async () => { + const dto = build({ offset: 0, limit: 20 }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('unreadOnly 가 boolean 이 아니면 거절', async () => { + const dto = build({ unreadOnly: 'yes', offset: 0, limit: 20 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('unreadOnly'); + }); + + it('상속된 페이지네이션 검증도 적용 (limit > 50)', async () => { + const dto = build({ unreadOnly: false, offset: 0, limit: 51 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('limit'); + }); +}); diff --git a/src/features/user/dto/inputs/my-notifications.input.ts b/src/features/user/dto/inputs/my-notifications.input.ts new file mode 100644 index 0000000..83e510a --- /dev/null +++ b/src/features/user/dto/inputs/my-notifications.input.ts @@ -0,0 +1,9 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MyNotificationsInput extends UserPaginationInput { + @IsOptional() + @IsBoolean() + unreadOnly?: boolean; +} diff --git a/src/features/user/dto/inputs/my-orders.input.spec.ts b/src/features/user/dto/inputs/my-orders.input.spec.ts new file mode 100644 index 0000000..cd3c618 --- /dev/null +++ b/src/features/user/dto/inputs/my-orders.input.spec.ts @@ -0,0 +1,56 @@ +import 'reflect-metadata'; + +import { OrderStatus } from '@prisma/client'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MyOrdersInput } from '@/features/user/dto/inputs/my-orders.input'; + +function build(plain: object): MyOrdersInput { + return plainToInstance(MyOrdersInput, plain); +} + +describe('MyOrdersInput', () => { + it('statuses 누락 허용', async () => { + const dto = build({ offset: 0, limit: 20 }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('유효한 OrderStatus 배열 허용', async () => { + const dto = build({ + statuses: [OrderStatus.SUBMITTED, OrderStatus.CONFIRMED], + offset: 0, + limit: 20, + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('statuses 가 배열이 아니면 거절', async () => { + const dto = build({ + statuses: OrderStatus.SUBMITTED as unknown as OrderStatus[], + offset: 0, + limit: 20, + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThanOrEqual(1); + expect(errors[0].property).toBe('statuses'); + }); + + it('알 수 없는 OrderStatus 값 거절', async () => { + const dto = build({ + statuses: ['UNKNOWN_STATUS'], + offset: 0, + limit: 20, + }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('statuses'); + }); + + it('상속된 페이지네이션 검증도 적용 (offset < 0)', async () => { + const dto = build({ offset: -1, limit: 20 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('offset'); + }); +}); diff --git a/src/features/user/dto/inputs/my-orders.input.ts b/src/features/user/dto/inputs/my-orders.input.ts new file mode 100644 index 0000000..699a358 --- /dev/null +++ b/src/features/user/dto/inputs/my-orders.input.ts @@ -0,0 +1,11 @@ +import { OrderStatus } from '@prisma/client'; +import { IsArray, IsEnum, IsOptional } from 'class-validator'; + +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MyOrdersInput extends UserPaginationInput { + @IsOptional() + @IsArray() + @IsEnum(OrderStatus, { each: true }) + statuses?: OrderStatus[]; +} diff --git a/src/features/user/dto/inputs/my-recent-viewed-products.input.ts b/src/features/user/dto/inputs/my-recent-viewed-products.input.ts new file mode 100644 index 0000000..9a3f73b --- /dev/null +++ b/src/features/user/dto/inputs/my-recent-viewed-products.input.ts @@ -0,0 +1,3 @@ +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MyRecentViewedProductsInput extends UserPaginationInput {} diff --git a/src/features/user/dto/inputs/my-reviews.input.ts b/src/features/user/dto/inputs/my-reviews.input.ts new file mode 100644 index 0000000..5e78054 --- /dev/null +++ b/src/features/user/dto/inputs/my-reviews.input.ts @@ -0,0 +1,3 @@ +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MyReviewsInput extends UserPaginationInput {} diff --git a/src/features/user/dto/inputs/my-search-histories.input.ts b/src/features/user/dto/inputs/my-search-histories.input.ts new file mode 100644 index 0000000..fd4b4ff --- /dev/null +++ b/src/features/user/dto/inputs/my-search-histories.input.ts @@ -0,0 +1,3 @@ +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MySearchHistoriesInput extends UserPaginationInput {} diff --git a/src/features/user/dto/inputs/my-wishlist.input.ts b/src/features/user/dto/inputs/my-wishlist.input.ts new file mode 100644 index 0000000..7b91c36 --- /dev/null +++ b/src/features/user/dto/inputs/my-wishlist.input.ts @@ -0,0 +1,3 @@ +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MyWishlistInput extends UserPaginationInput {} diff --git a/src/features/user/dto/inputs/update-my-profile-image.input.spec.ts b/src/features/user/dto/inputs/update-my-profile-image.input.spec.ts new file mode 100644 index 0000000..ca68334 --- /dev/null +++ b/src/features/user/dto/inputs/update-my-profile-image.input.spec.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { UpdateMyProfileImageInput } from '@/features/user/dto/inputs/update-my-profile-image.input'; + +function build(plain: object): UpdateMyProfileImageInput { + return plainToInstance(UpdateMyProfileImageInput, plain); +} + +describe('UpdateMyProfileImageInput', () => { + it('정상 URL 허용', async () => { + const dto = build({ + profileImageUrl: 'https://cdn.caquick.site/u/1/profile.jpg', + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('빈 문자열 거절', async () => { + const dto = build({ profileImageUrl: '' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('profileImageUrl'); + expect(errors[0].constraints).toHaveProperty('minLength'); + }); + + it('공백만 입력 시 trim 후 빈 문자열로 거절', async () => { + const dto = build({ profileImageUrl: ' ' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('profileImageUrl'); + }); + + it('2048 초과 거절', async () => { + const dto = build({ profileImageUrl: 'https://x/' + 'a'.repeat(2050) }); + const errors = await validate(dto); + expect(errors[0].property).toBe('profileImageUrl'); + expect(errors[0].constraints).toHaveProperty('maxLength'); + }); + + it('누락 거절', async () => { + const dto = build({}); + const errors = await validate(dto); + expect(errors[0].property).toBe('profileImageUrl'); + }); +}); diff --git a/src/features/user/dto/inputs/update-my-profile-image.input.ts b/src/features/user/dto/inputs/update-my-profile-image.input.ts new file mode 100644 index 0000000..5f75898 --- /dev/null +++ b/src/features/user/dto/inputs/update-my-profile-image.input.ts @@ -0,0 +1,13 @@ +import type { TransformFnParams } from 'class-transformer'; +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpdateMyProfileImageInput { + @Transform(({ value }: TransformFnParams): unknown => + typeof value === 'string' ? value.trim() : (value as unknown), + ) + @IsString() + @MinLength(1, { message: 'profileImageUrl is required.' }) + @MaxLength(2048, { message: 'profileImageUrl is too long.' }) + profileImageUrl!: string; +} diff --git a/src/features/user/dto/inputs/update-my-profile.input.spec.ts b/src/features/user/dto/inputs/update-my-profile.input.spec.ts new file mode 100644 index 0000000..74eebcb --- /dev/null +++ b/src/features/user/dto/inputs/update-my-profile.input.spec.ts @@ -0,0 +1,41 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { UpdateMyProfileInput } from '@/features/user/dto/inputs/update-my-profile.input'; + +function build(plain: object): UpdateMyProfileInput { + return plainToInstance(UpdateMyProfileInput, plain); +} + +describe('UpdateMyProfileInput', () => { + it('빈 입력 통과 (도메인 검증은 서비스 책임)', async () => { + const dto = build({}); + expect(await validate(dto)).toHaveLength(0); + }); + + it('nickname 단독 수정 허용', async () => { + const dto = build({ nickname: 'newnick' }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('name trim 후 빈 문자열 거절', async () => { + const dto = build({ name: ' ' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('name'); + expect(errors[0].constraints).toHaveProperty('minLength'); + }); + + it('nickname 잘못된 길이 거절', async () => { + const dto = build({ nickname: 'a' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('nickname'); + }); + + it('phoneNumber 형식 오류 거절', async () => { + const dto = build({ phoneNumber: '02-1234-5678' }); + const errors = await validate(dto); + expect(errors[0].property).toBe('phoneNumber'); + }); +}); diff --git a/src/features/user/dto/inputs/update-my-profile.input.ts b/src/features/user/dto/inputs/update-my-profile.input.ts new file mode 100644 index 0000000..9729093 --- /dev/null +++ b/src/features/user/dto/inputs/update-my-profile.input.ts @@ -0,0 +1,61 @@ +import type { TransformFnParams } from 'class-transformer'; +import { Transform } from 'class-transformer'; +import { + IsDate, + IsOptional, + IsString, + Length, + Matches, + MaxLength, + MinDate, + MinLength, +} from 'class-validator'; + +import { + MAX_NICKNAME_LENGTH, + MIN_BIRTH_DATE, + MIN_NICKNAME_LENGTH, + PHONE_FORMAT_EXAMPLE, + PHONE_REGEX, +} from '@/features/user/constants/user.constants'; + +const NICKNAME_REGEX = /^[A-Za-z0-9가-힣_]+$/; + +/** + * 프로필 부분 수정 입력. + * + * "최소 한 필드 이상 전송" 규칙은 도메인 invariant 이므로 service 에서 검증한다 + * (class-validator 만으로 깔끔히 표현하기 어렵다). + */ +export class UpdateMyProfileInput { + @IsOptional() + @IsString() + @Length(MIN_NICKNAME_LENGTH, MAX_NICKNAME_LENGTH) + @Matches(NICKNAME_REGEX, { + message: 'Nickname contains invalid characters.', + }) + nickname?: string | null; + + @IsOptional() + @Transform(({ value }: TransformFnParams): unknown => + typeof value === 'string' ? value.trim() : (value as unknown), + ) + @IsString() + @MinLength(1, { message: 'Name cannot be empty.' }) + @MaxLength(50) + name?: string | null; + + @IsOptional() + @IsDate() + @MinDate(MIN_BIRTH_DATE, { + message: 'birthDate is too old (before 1900-01-01).', + }) + birthDate?: Date | null; + + @IsOptional() + @IsString() + @Matches(PHONE_REGEX, { + message: `Invalid phone number format. Expected ${PHONE_FORMAT_EXAMPLE}.`, + }) + phoneNumber?: string | null; +} diff --git a/src/features/user/dto/inputs/user-pagination.input.spec.ts b/src/features/user/dto/inputs/user-pagination.input.spec.ts new file mode 100644 index 0000000..8a73d23 --- /dev/null +++ b/src/features/user/dto/inputs/user-pagination.input.spec.ts @@ -0,0 +1,55 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +function build(plain: object): UserPaginationInput { + return plainToInstance(UserPaginationInput, plain); +} + +describe('UserPaginationInput', () => { + it('필드 누락 허용 (SDL 기본값 의존)', async () => { + const dto = build({}); + expect(await validate(dto)).toHaveLength(0); + }); + + it('유효 입력 통과', async () => { + const dto = build({ offset: 0, limit: 20 }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('offset 음수 거절', async () => { + const dto = build({ offset: -1, limit: 20 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('offset'); + }); + + it('limit 0 거절', async () => { + const dto = build({ offset: 0, limit: 0 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('limit'); + }); + + it('limit 51 거절 (운영 상한 50)', async () => { + const dto = build({ offset: 0, limit: 51 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('limit'); + }); + + it('limit 50 경계 허용', async () => { + const dto = build({ offset: 0, limit: 50 }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('정수가 아닌 값 거절', async () => { + const dto = build({ offset: 1.5, limit: 20 }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toHaveProperty('isInt'); + }); +}); diff --git a/src/features/user/dto/inputs/user-pagination.input.ts b/src/features/user/dto/inputs/user-pagination.input.ts new file mode 100644 index 0000000..8576f3c --- /dev/null +++ b/src/features/user/dto/inputs/user-pagination.input.ts @@ -0,0 +1,21 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +/** + * 유저 도메인 페이지네이션 공통 입력. + * + * SDL 기본값: offset=0, limit=20. limit 최대 50 (운영 보호). + * common 의 PaginationInput 은 limit 최대 100 이라 도메인별 정책 차이로 + * 분리한다. + */ +export class UserPaginationInput { + @IsOptional() + @IsInt() + @Min(0) + offset?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/src/features/user/dto/inputs/write-review-media.input.spec.ts b/src/features/user/dto/inputs/write-review-media.input.spec.ts new file mode 100644 index 0000000..bbfcfb5 --- /dev/null +++ b/src/features/user/dto/inputs/write-review-media.input.spec.ts @@ -0,0 +1,51 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { WriteReviewMediaInput } from '@/features/user/dto/inputs/write-review-media.input'; + +function build(plain: object): WriteReviewMediaInput { + return plainToInstance(WriteReviewMediaInput, plain); +} + +describe('WriteReviewMediaInput', () => { + it('IMAGE + 필수 필드 통과', async () => { + const dto = build({ + mediaType: 'IMAGE', + mediaUrl: 'https://x/y.jpg', + sortOrder: 0, + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('VIDEO + thumbnailUrl 통과', async () => { + const dto = build({ + mediaType: 'VIDEO', + mediaUrl: 'https://x/y.mp4', + thumbnailUrl: 'https://x/y-thumb.jpg', + sortOrder: 0, + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('알 수 없는 mediaType 거절', async () => { + const dto = build({ + mediaType: 'AUDIO', + mediaUrl: 'https://x/y.mp3', + sortOrder: 0, + }); + const errors = await validate(dto); + expect(errors[0].property).toBe('mediaType'); + }); + + it('sortOrder 음수 거절', async () => { + const dto = build({ + mediaType: 'IMAGE', + mediaUrl: 'https://x/y.jpg', + sortOrder: -1, + }); + const errors = await validate(dto); + expect(errors[0].property).toBe('sortOrder'); + }); +}); diff --git a/src/features/user/dto/inputs/write-review-media.input.ts b/src/features/user/dto/inputs/write-review-media.input.ts new file mode 100644 index 0000000..4233a1b --- /dev/null +++ b/src/features/user/dto/inputs/write-review-media.input.ts @@ -0,0 +1,19 @@ +import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export type ReviewMediaTypeInput = 'IMAGE' | 'VIDEO'; + +export class WriteReviewMediaInput { + @IsIn(['IMAGE', 'VIDEO']) + mediaType!: ReviewMediaTypeInput; + + @IsString() + mediaUrl!: string; + + @IsOptional() + @IsString() + thumbnailUrl?: string; + + @IsInt() + @Min(0) + sortOrder!: number; +} diff --git a/src/features/user/dto/inputs/write-review.input.spec.ts b/src/features/user/dto/inputs/write-review.input.spec.ts new file mode 100644 index 0000000..c66ecd4 --- /dev/null +++ b/src/features/user/dto/inputs/write-review.input.spec.ts @@ -0,0 +1,86 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { WriteReviewInput } from '@/features/user/dto/inputs/write-review.input'; + +function build(plain: object): WriteReviewInput { + return plainToInstance(WriteReviewInput, plain); +} + +describe('WriteReviewInput', () => { + const goodContent = 'a'.repeat(20); + + it('필수 필드 통과', async () => { + const dto = build({ + orderItemId: '123', + rating: 4.5, + content: goodContent, + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('미디어 배열 nested 검증 통과', async () => { + const dto = build({ + orderItemId: '123', + rating: 4.5, + content: goodContent, + media: [ + { + mediaType: 'IMAGE', + mediaUrl: 'https://x/y.jpg', + sortOrder: 0, + }, + ], + }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('rating 0.3 단위 거절', async () => { + const dto = build({ + orderItemId: '123', + rating: 4.3, + content: goodContent, + }); + const errors = await validate(dto); + expect(errors[0].property).toBe('rating'); + }); + + it('content 길이 20 미만 거절', async () => { + const dto = build({ + orderItemId: '123', + rating: 4.5, + content: '짧음', + }); + const errors = await validate(dto); + expect(errors[0].property).toBe('content'); + }); + + it('content 길이 1001 초과 거절', async () => { + const dto = build({ + orderItemId: '123', + rating: 4.5, + content: 'a'.repeat(1001), + }); + const errors = await validate(dto); + expect(errors[0].property).toBe('content'); + }); + + it('미디어 항목 mediaType 오류는 nested 에러로 보고', async () => { + const dto = build({ + orderItemId: '123', + rating: 4.5, + content: goodContent, + media: [ + { + mediaType: 'AUDIO', + mediaUrl: 'https://x/y.mp3', + sortOrder: 0, + }, + ], + }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'media')).toBe(true); + }); +}); diff --git a/src/features/user/dto/inputs/write-review.input.ts b/src/features/user/dto/inputs/write-review.input.ts new file mode 100644 index 0000000..627388d --- /dev/null +++ b/src/features/user/dto/inputs/write-review.input.ts @@ -0,0 +1,37 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsNumber, + IsOptional, + IsString, + Length, + ValidateNested, +} from 'class-validator'; + +import { WriteReviewMediaInput } from '@/features/user/dto/inputs/write-review-media.input'; +import { IsRatingValid } from '@/features/user/dto/validators/rating.validator'; + +/** + * 리뷰 작성 입력. + * + * 미디어 개수 상한(이미지 10, 동영상 1) 같은 도메인 invariant 는 service 에서 + * 검증. 여기서는 각 항목의 형식과 별점/내용 길이만 본다. + */ +export class WriteReviewInput { + @IsString() + orderItemId!: string; + + @IsNumber() + @IsRatingValid() + rating!: number; + + @IsString() + @Length(20, 1000) + content!: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WriteReviewMediaInput) + media?: WriteReviewMediaInput[]; +} diff --git a/src/features/user/dto/validators/rating.validator.spec.ts b/src/features/user/dto/validators/rating.validator.spec.ts new file mode 100644 index 0000000..c4f51da --- /dev/null +++ b/src/features/user/dto/validators/rating.validator.spec.ts @@ -0,0 +1,48 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { IsRatingValid } from '@/features/user/dto/validators/rating.validator'; + +class Sample { + @IsRatingValid() + rating!: number; +} + +function build(plain: object): Sample { + return plainToInstance(Sample, plain); +} + +describe('IsRatingValid', () => { + it.each([ + ['1.0', 1.0], + ['1.5', 1.5], + ['3.0', 3.0], + ['4.5', 4.5], + ['5.0', 5.0], + ])('허용: %s', async (_label, value) => { + const dto = build({ rating: value }); + expect(await validate(dto)).toHaveLength(0); + }); + + it.each([ + ['0.5 미만', 0.5], + ['5.5 초과', 5.5], + ['0.3 단위', 1.3], + ['소수점 4자리', 1.234], + ['NaN', NaN], + ['Infinity', Infinity], + ])('거절: %s', async (_label, value) => { + const dto = build({ rating: value }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('rating'); + }); + + it('숫자가 아닌 값 거절', async () => { + const dto = build({ rating: '4.5' }); + const errors = await validate(dto); + expect(errors).toHaveLength(1); + }); +}); diff --git a/src/features/user/dto/validators/rating.validator.ts b/src/features/user/dto/validators/rating.validator.ts new file mode 100644 index 0000000..b6d9a53 --- /dev/null +++ b/src/features/user/dto/validators/rating.validator.ts @@ -0,0 +1,33 @@ +import type { + ValidationArguments, + ValidationOptions, + ValidatorConstraintInterface, +} from 'class-validator'; +import { Validate, ValidatorConstraint } from 'class-validator'; + +/** + * 리뷰 별점 검증: 1.0~5.0, 0.5 단위. + * + * 왜: WriteReview 의 rating 필드 룰. SDL Float! 형식만으로는 범위/스텝 + * 제약을 표현할 수 없어 데코레이터로 도메인 규칙을 명시. + */ +@ValidatorConstraint({ name: 'IsRatingValid', async: false }) +export class IsRatingValidConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== 'number') return false; + if (!Number.isFinite(value)) return false; + if (value < 1 || value > 5) return false; + // 0.5 단위: value * 2 가 정수여야 함 + return Number.isInteger(value * 2); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be 1.0~5.0 in 0.5 steps.`; + } +} + +export function IsRatingValid( + validationOptions?: ValidationOptions, +): PropertyDecorator { + return Validate(IsRatingValidConstraint, [], validationOptions); +} From 71b0915f986a9b9f557c9c50271cbacb74d98ee3 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 24 May 2026 03:16:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor(user):=20Resolver/Service=20DTO=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20+=20=EC=A4=91=EB=B3=B5=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A-2 검증 전략 P0-3 단계 3 (User Input 전수) — 적용편. - User Resolver 7종이 신규 DTO class 를 @Args 타입으로 사용. GraphQL ValidationPipe 가 정식 검증 수행. - user-{order,wishlist,recent-view,review}.service: offset/limit 수동 검증 제거 (DTO 가 처리) - user-review.service: rating · content 길이 수동 검증 제거 (DTO 가 처리). validateMedia(이미지 10 / 영상 1) invariant 만 잔존. - user-profile.service: profileImageUrl 의 길이/공백 수동 검증 제거 (DTO 가 처리). Name 빈 문자열 reject 는 service 에 방어 코드로 유지. - types/user-{input,order-input,review-input}.type.ts 삭제 (interface → class 마이그레이션 완료, 더 이상 사용처 없음) - user-wishlist-output.type.ts 에 잘못 위치해 있던 MyWishlistInput 제거 - spec 정리: DTO 레이어로 이전된 검증 케이스(15+) 제거. service spec 은 도메인 분기에 집중. FE 영향: 정상 입력은 응답 동일. 잘못된 형식은 ValidationPipe 가 GraphQL BAD_REQUEST 로 일관 응답 (메시지 본문은 기존 한국어 그대로 유지하도록 룰별 message 옵션 명시). --- .../user-notification-query.resolver.ts | 2 +- .../resolvers/user-order-query.resolver.ts | 2 +- .../user-profile-mutation.resolver.ts | 11 ++- .../user-recent-view-query.resolver.ts | 3 +- .../user-review-mutation.resolver.ts | 6 +- .../resolvers/user-review-query.resolver.ts | 2 +- .../resolvers/user-review.resolver.spec.ts | 17 +---- .../resolvers/user-search-query.resolver.ts | 2 +- .../resolvers/user-wishlist-query.resolver.ts | 6 +- .../services/user-notification.service.ts | 2 +- .../user/services/user-order.service.spec.ts | 23 +------ .../user/services/user-order.service.ts | 17 +---- .../services/user-profile.service.spec.ts | 20 +----- .../user/services/user-profile.service.ts | 20 ++---- .../services/user-recent-view.service.spec.ts | 22 +----- .../user/services/user-recent-view.service.ts | 18 +---- .../user/services/user-review.service.spec.ts | 68 +------------------ .../user/services/user-review.service.ts | 39 ++--------- .../user/services/user-search.service.ts | 2 +- .../services/user-wishlist.service.spec.ts | 16 +---- .../user/services/user-wishlist.service.ts | 24 ++----- src/features/user/types/user-input.type.ts | 28 -------- .../user/types/user-order-input.type.ts | 7 -- .../user/types/user-review-input.type.ts | 24 ------- .../user/types/user-wishlist-output.type.ts | 5 -- 25 files changed, 51 insertions(+), 335 deletions(-) delete mode 100644 src/features/user/types/user-input.type.ts delete mode 100644 src/features/user/types/user-order-input.type.ts delete mode 100644 src/features/user/types/user-review-input.type.ts diff --git a/src/features/user/resolvers/user-notification-query.resolver.ts b/src/features/user/resolvers/user-notification-query.resolver.ts index 7560fca..0ceb674 100644 --- a/src/features/user/resolvers/user-notification-query.resolver.ts +++ b/src/features/user/resolvers/user-notification-query.resolver.ts @@ -1,8 +1,8 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MyNotificationsInput } from '@/features/user/dto/inputs/my-notifications.input'; import { UserNotificationService } from '@/features/user/services/user-notification.service'; -import type { MyNotificationsInput } from '@/features/user/types/user-input.type'; import type { NotificationConnection, ViewerCounts, diff --git a/src/features/user/resolvers/user-order-query.resolver.ts b/src/features/user/resolvers/user-order-query.resolver.ts index 2634486..eaa575a 100644 --- a/src/features/user/resolvers/user-order-query.resolver.ts +++ b/src/features/user/resolvers/user-order-query.resolver.ts @@ -2,8 +2,8 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; import { parseId } from '@/common/utils/id-parser'; +import { MyOrdersInput } from '@/features/user/dto/inputs/my-orders.input'; import { UserOrderService } from '@/features/user/services/user-order.service'; -import type { MyOrdersInput } from '@/features/user/types/user-order-input.type'; import type { MyOrderConnection, MyOrderDetail, diff --git a/src/features/user/resolvers/user-profile-mutation.resolver.ts b/src/features/user/resolvers/user-profile-mutation.resolver.ts index 867ab5b..3b60101 100644 --- a/src/features/user/resolvers/user-profile-mutation.resolver.ts +++ b/src/features/user/resolvers/user-profile-mutation.resolver.ts @@ -1,12 +1,11 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { CompleteOnboardingInput } from '@/features/user/dto/inputs/complete-onboarding.input'; +import { CreateProfileImageUploadUrlInput } from '@/features/user/dto/inputs/create-profile-image-upload-url.input'; +import { UpdateMyProfileImageInput } from '@/features/user/dto/inputs/update-my-profile-image.input'; +import { UpdateMyProfileInput } from '@/features/user/dto/inputs/update-my-profile.input'; import { UserProfileService } from '@/features/user/services/user-profile.service'; -import type { - CompleteOnboardingInput, - UpdateMyProfileImageInput, - UpdateMyProfileInput, -} from '@/features/user/types/user-input.type'; import type { MePayload, ProfileImageUploadUrl, @@ -53,7 +52,7 @@ export class UserProfileMutationResolver { @Mutation('createProfileImageUploadUrl') createProfileImageUploadUrl( @CurrentUser() user: JwtUser, - @Args('input') input: { contentType: string; contentLength: number }, + @Args('input') input: CreateProfileImageUploadUrlInput, ): Promise { const accountId = parseAccountId(user); return this.profileService.createProfileImageUploadUrl(accountId, input); diff --git a/src/features/user/resolvers/user-recent-view-query.resolver.ts b/src/features/user/resolvers/user-recent-view-query.resolver.ts index c9ef800..a61b6ce 100644 --- a/src/features/user/resolvers/user-recent-view-query.resolver.ts +++ b/src/features/user/resolvers/user-recent-view-query.resolver.ts @@ -1,6 +1,7 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MyRecentViewedProductsInput } from '@/features/user/dto/inputs/my-recent-viewed-products.input'; import { UserRecentViewService } from '@/features/user/services/user-recent-view.service'; import { CurrentUser, @@ -17,7 +18,7 @@ export class UserRecentViewQueryResolver { @Query('myRecentViewedProducts') myRecentViewedProducts( @CurrentUser() user: JwtUser, - @Args('input') input?: { offset?: number; limit?: number }, + @Args('input') input?: MyRecentViewedProductsInput, ) { const accountId = parseAccountId(user); return this.recentViewService.list(accountId, input); diff --git a/src/features/user/resolvers/user-review-mutation.resolver.ts b/src/features/user/resolvers/user-review-mutation.resolver.ts index 0f1bd5b..08c785f 100644 --- a/src/features/user/resolvers/user-review-mutation.resolver.ts +++ b/src/features/user/resolvers/user-review-mutation.resolver.ts @@ -1,11 +1,9 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { CreateReviewMediaUploadUrlInput } from '@/features/user/dto/inputs/create-review-media-upload-url.input'; +import { WriteReviewInput } from '@/features/user/dto/inputs/write-review.input'; import { UserReviewService } from '@/features/user/services/user-review.service'; -import type { - CreateReviewMediaUploadUrlInput, - WriteReviewInput, -} from '@/features/user/types/user-review-input.type'; import type { MyReview, ReviewMediaUploadUrl, diff --git a/src/features/user/resolvers/user-review-query.resolver.ts b/src/features/user/resolvers/user-review-query.resolver.ts index 3385ff2..60c9181 100644 --- a/src/features/user/resolvers/user-review-query.resolver.ts +++ b/src/features/user/resolvers/user-review-query.resolver.ts @@ -1,8 +1,8 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MyReviewsInput } from '@/features/user/dto/inputs/my-reviews.input'; import { UserReviewService } from '@/features/user/services/user-review.service'; -import type { MyReviewsInput } from '@/features/user/types/user-review-input.type'; import type { MyReviewConnection, MyReviewOrNull, diff --git a/src/features/user/resolvers/user-review.resolver.spec.ts b/src/features/user/resolvers/user-review.resolver.spec.ts index 974a9e7..8d6c3a2 100644 --- a/src/features/user/resolvers/user-review.resolver.spec.ts +++ b/src/features/user/resolvers/user-review.resolver.spec.ts @@ -1,4 +1,3 @@ -import { BadRequestException } from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; import { ReviewRepository } from '@/features/user/repositories/review.repository'; @@ -91,20 +90,8 @@ describe('User Review Resolvers (real DB)', () => { expect(saved.account_id).toBe(ctx.accountId); }); - it('Mutation.writeReview: 유효성 실패는 BadRequestException이 전파된다', async () => { - const ctx = await setupReviewableItem(); - - await expect( - mutationResolver.writeReview( - { accountId: ctx.accountId.toString() }, - { - orderItemId: ctx.orderItemId.toString(), - rating: 5, - content: '너무짧음', - }, - ), - ).rejects.toThrow(BadRequestException); - }); + // 입력 형식 검증(rating · content 길이 등)은 DTO + ValidationPipe 의 책임. + // 본 resolver 통합 테스트는 도메인 동작(DB 흐름)만 확인한다. it('Query.myReviews: 본인 리뷰 목록이 DB에서 조회되어 반환된다', async () => { const ctx = await setupReviewableItem(); diff --git a/src/features/user/resolvers/user-search-query.resolver.ts b/src/features/user/resolvers/user-search-query.resolver.ts index 1c663ef..71abe94 100644 --- a/src/features/user/resolvers/user-search-query.resolver.ts +++ b/src/features/user/resolvers/user-search-query.resolver.ts @@ -1,8 +1,8 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MySearchHistoriesInput } from '@/features/user/dto/inputs/my-search-histories.input'; import { UserSearchService } from '@/features/user/services/user-search.service'; -import type { MySearchHistoriesInput } from '@/features/user/types/user-input.type'; import type { SearchHistoryConnection } from '@/features/user/types/user-output.type'; import { CurrentUser, diff --git a/src/features/user/resolvers/user-wishlist-query.resolver.ts b/src/features/user/resolvers/user-wishlist-query.resolver.ts index baf8b9a..0385598 100644 --- a/src/features/user/resolvers/user-wishlist-query.resolver.ts +++ b/src/features/user/resolvers/user-wishlist-query.resolver.ts @@ -1,11 +1,9 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MyWishlistInput } from '@/features/user/dto/inputs/my-wishlist.input'; import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; -import type { - MyWishlistConnection, - MyWishlistInput, -} from '@/features/user/types/user-wishlist-output.type'; +import type { MyWishlistConnection } from '@/features/user/types/user-wishlist-output.type'; import { CurrentUser, JwtAuthGuard, diff --git a/src/features/user/services/user-notification.service.ts b/src/features/user/services/user-notification.service.ts index 563a58a..19d21bc 100644 --- a/src/features/user/services/user-notification.service.ts +++ b/src/features/user/services/user-notification.service.ts @@ -1,8 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import type { MyNotificationsInput } from '@/features/user/dto/inputs/my-notifications.input'; import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserBaseService } from '@/features/user/services/user-base.service'; -import type { MyNotificationsInput } from '@/features/user/types/user-input.type'; import type { NotificationConnection, ViewerCounts, diff --git a/src/features/user/services/user-order.service.spec.ts b/src/features/user/services/user-order.service.spec.ts index 6cf9019..88f4f6b 100644 --- a/src/features/user/services/user-order.service.spec.ts +++ b/src/features/user/services/user-order.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; import { OrderRepository } from '@/features/order/repositories/order.repository'; @@ -151,26 +151,7 @@ describe('UserOrderService (real DB)', () => { expect(result.totalCount).toBe(1); }); - it('offset 음수면 BadRequestException', async () => { - const account = await setupUser(); - await expect( - service.listMyOrders(account.id, { offset: -1 }), - ).rejects.toThrow(BadRequestException); - }); - - it('limit이 0 이하면 BadRequestException', async () => { - const account = await setupUser(); - await expect( - service.listMyOrders(account.id, { limit: 0 }), - ).rejects.toThrow(BadRequestException); - }); - - it('limit이 상한(50) 초과면 BadRequestException', async () => { - const account = await setupUser(); - await expect( - service.listMyOrders(account.id, { limit: 51 }), - ).rejects.toThrow(BadRequestException); - }); + // offset/limit 범위 검증은 DTO (MyOrdersInput → UserPaginationInput) 로 이전됨. it('주문이 0건이면 빈 connection을 반환한다', async () => { const account = await setupUser(); diff --git a/src/features/user/services/user-order.service.ts b/src/features/user/services/user-order.service.ts index 594165c..0b35b37 100644 --- a/src/features/user/services/user-order.service.ts +++ b/src/features/user/services/user-order.service.ts @@ -1,21 +1,15 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { OrderStatus } from '@prisma/client'; import { formatBusinessHours } from '@/common/utils/business-hours-formatter'; import { OrderRepository } from '@/features/order/repositories/order.repository'; import { USER_ORDER_ERRORS } from '@/features/user/constants/user-order-error-messages'; -import type { MyOrdersInput } from '@/features/user/types/user-order-input.type'; +import type { MyOrdersInput } from '@/features/user/dto/inputs/my-orders.input'; import type { MyOrderConnection, MyOrderDetail, } from '@/features/user/types/user-order-output.type'; -const MAX_LIMIT = 50; - @Injectable() export class UserOrderService { constructor(private readonly orderRepository: OrderRepository) {} @@ -28,13 +22,6 @@ export class UserOrderService { const limit = input?.limit ?? 20; const statuses = input?.statuses; - if (offset < 0) { - throw new BadRequestException(USER_ORDER_ERRORS.INVALID_OFFSET); - } - if (limit < 1 || limit > MAX_LIMIT) { - throw new BadRequestException(USER_ORDER_ERRORS.INVALID_LIMIT); - } - const [orders, totalCount] = await Promise.all([ this.orderRepository.findOrdersByAccount({ accountId, diff --git a/src/features/user/services/user-profile.service.spec.ts b/src/features/user/services/user-profile.service.spec.ts index a22ab44..dcd1238 100644 --- a/src/features/user/services/user-profile.service.spec.ts +++ b/src/features/user/services/user-profile.service.spec.ts @@ -425,25 +425,7 @@ describe('UserProfileService (real DB)', () => { ); }); - it('URL이 공백-only면 BadRequestException을 던진다', async () => { - const account = await createAccount(prisma, { account_type: 'USER' }); - await createUserProfile(prisma, { account_id: account.id }); - - await expect( - service.updateMyProfileImage(account.id, { profileImageUrl: ' ' }), - ).rejects.toThrow(BadRequestException); - }); - - it('URL이 2048자를 초과하면 BadRequestException을 던진다', async () => { - const account = await createAccount(prisma, { account_type: 'USER' }); - await createUserProfile(prisma, { account_id: account.id }); - - await expect( - service.updateMyProfileImage(account.id, { - profileImageUrl: 'https://s3.example.com/' + 'a'.repeat(2030), - }), - ).rejects.toThrow(BadRequestException); - }); + // profileImageUrl 형식·길이 검증은 DTO (UpdateMyProfileImageInput) 로 이전됨. }); // ─── checkNicknameAvailability ─── diff --git a/src/features/user/services/user-profile.service.ts b/src/features/user/services/user-profile.service.ts index 9564388..eb75c5b 100644 --- a/src/features/user/services/user-profile.service.ts +++ b/src/features/user/services/user-profile.service.ts @@ -8,13 +8,11 @@ import { MAX_NICKNAME_LENGTH, MIN_NICKNAME_LENGTH, } from '@/features/user/constants/user.constants'; +import type { CompleteOnboardingInput } from '@/features/user/dto/inputs/complete-onboarding.input'; +import type { UpdateMyProfileImageInput } from '@/features/user/dto/inputs/update-my-profile-image.input'; +import type { UpdateMyProfileInput } from '@/features/user/dto/inputs/update-my-profile.input'; import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserBaseService } from '@/features/user/services/user-base.service'; -import type { - CompleteOnboardingInput, - UpdateMyProfileImageInput, - UpdateMyProfileInput, -} from '@/features/user/types/user-input.type'; import type { MePayload, NicknameAvailability, @@ -90,7 +88,8 @@ export class UserProfileService extends UserBaseService { if (isTaken) throw new ConflictException('Nickname already exists.'); } - // figma 명세: 이름은 필수값. 전송되었지만 trim 후 빈 문자열이면 reject. + // figma 명세: 이름은 필수값. DTO 가 trim + 빈 문자열 거절을 담당하지만, + // 서비스 단으로 들어온 이상 정규화 결과가 null 인 경우는 방어적으로 reject. let name: string | undefined = undefined; if (hasName) { const normalized = this.normalizeName(input.name); @@ -124,13 +123,8 @@ export class UserProfileService extends UserBaseService { ): Promise { await this.requireActiveUser(accountId); - const profileImageUrl = input.profileImageUrl.trim(); - if (profileImageUrl.length === 0) { - throw new BadRequestException('profileImageUrl is required.'); - } - if (profileImageUrl.length > 2048) { - throw new BadRequestException('profileImageUrl is too long.'); - } + // DTO 의 @Transform 이 trim, @MinLength/@MaxLength 가 길이를 보장. + const profileImageUrl = input.profileImageUrl; await this.repo.updateProfileImage({ accountId, diff --git a/src/features/user/services/user-recent-view.service.spec.ts b/src/features/user/services/user-recent-view.service.spec.ts index b9f3ed5..ffd6330 100644 --- a/src/features/user/services/user-recent-view.service.spec.ts +++ b/src/features/user/services/user-recent-view.service.spec.ts @@ -188,26 +188,8 @@ describe('UserRecentViewService (real DB)', () => { expect(result.totalCount).toBe(0); }); - it('limit이 0 이하면 BadRequestException을 던진다', async () => { - const account = await createAccount(prisma, { account_type: 'USER' }); - await expect(service.list(account.id, { limit: 0 })).rejects.toThrow( - BadRequestException, - ); - }); - - it('limit이 상한(50)을 초과하면 BadRequestException을 던진다', async () => { - const account = await createAccount(prisma, { account_type: 'USER' }); - await expect(service.list(account.id, { limit: 51 })).rejects.toThrow( - BadRequestException, - ); - }); - - it('offset이 음수면 BadRequestException을 던진다', async () => { - const account = await createAccount(prisma, { account_type: 'USER' }); - await expect(service.list(account.id, { offset: -1 })).rejects.toThrow( - BadRequestException, - ); - }); + // offset/limit 범위 검증은 DTO (MyRecentViewedProductsInput → UserPaginationInput) + // 로 이전됨. service 테스트는 도메인 로직에 집중. }); // ─── record ─── diff --git a/src/features/user/services/user-recent-view.service.ts b/src/features/user/services/user-recent-view.service.ts index 163ce4a..6878e9b 100644 --- a/src/features/user/services/user-recent-view.service.ts +++ b/src/features/user/services/user-recent-view.service.ts @@ -1,11 +1,8 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { parseId } from '@/common/utils/id-parser'; import { ProductRepository } from '@/features/product/repositories/product.repository'; +import type { MyRecentViewedProductsInput } from '@/features/user/dto/inputs/my-recent-viewed-products.input'; import { RecentProductViewRepository } from '@/features/user/repositories/recent-product-view.repository'; import { UserRepository } from '@/features/user/repositories/user.repository'; import type { RecentViewedProductConnection } from '@/features/user/types/user-mypage-output.type'; @@ -14,8 +11,6 @@ import type { RecentViewedProductConnection } from '@/features/user/types/user-m const MAX_RECENT_VIEWS = 50; const RECENT_VIEW_ERRORS = { - INVALID_LIMIT: '조회 개수는 1~50 사이여야 합니다.', - INVALID_OFFSET: '오프셋은 0 이상이어야 합니다.', PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.', } as const; @@ -29,18 +24,11 @@ export class UserRecentViewService { async list( accountId: bigint, - input?: { offset?: number; limit?: number }, + input?: MyRecentViewedProductsInput, ): Promise { const offset = input?.offset ?? 0; const limit = input?.limit ?? 20; - if (offset < 0) { - throw new BadRequestException(RECENT_VIEW_ERRORS.INVALID_OFFSET); - } - if (limit < 1 || limit > 50) { - throw new BadRequestException(RECENT_VIEW_ERRORS.INVALID_LIMIT); - } - const { items, totalCount } = await this.recentViewRepo.findRecentByAccountPaginated({ accountId, diff --git a/src/features/user/services/user-review.service.spec.ts b/src/features/user/services/user-review.service.spec.ts index aede91b..ed48fc6 100644 --- a/src/features/user/services/user-review.service.spec.ts +++ b/src/features/user/services/user-review.service.spec.ts @@ -122,58 +122,8 @@ describe('UserReviewService (real DB)', () => { expect(saved.media.some((m) => m.media_type === 'VIDEO')).toBe(true); }); - it('rating이 1 미만이거나 0.5 단위가 아니면 BadRequestException', async () => { - const ctx = await setupReviewableOrderItem(); - - await expect( - service.writeReview(ctx.accountId, { - orderItemId: ctx.orderItemId.toString(), - rating: 0, - content: VALID_CONTENT, - }), - ).rejects.toThrow(BadRequestException); - - await expect( - service.writeReview(ctx.accountId, { - orderItemId: ctx.orderItemId.toString(), - rating: 4.3, - content: VALID_CONTENT, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('rating이 5 초과면 BadRequestException', async () => { - const ctx = await setupReviewableOrderItem(); - await expect( - service.writeReview(ctx.accountId, { - orderItemId: ctx.orderItemId.toString(), - rating: 6, - content: VALID_CONTENT, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('content가 20자 미만이면 BadRequestException', async () => { - const ctx = await setupReviewableOrderItem(); - await expect( - service.writeReview(ctx.accountId, { - orderItemId: ctx.orderItemId.toString(), - rating: 5, - content: '짧아요', - }), - ).rejects.toThrow(BadRequestException); - }); - - it('content가 1000자 초과면 BadRequestException', async () => { - const ctx = await setupReviewableOrderItem(); - await expect( - service.writeReview(ctx.accountId, { - orderItemId: ctx.orderItemId.toString(), - rating: 5, - content: 'a'.repeat(1001), - }), - ).rejects.toThrow(BadRequestException); - }); + // rating(범위/0.5 단위) · content 길이 검증은 DTO (WriteReviewInput + + // IsRatingValid) 로 이전됨. service 테스트는 미디어 카운트·도메인 분기에 집중. it('사진 10장 + 동영상 1개(총 11개)는 통과한다', async () => { const ctx = await setupReviewableOrderItem(); @@ -407,19 +357,7 @@ describe('UserReviewService (real DB)', () => { expect(result.totalCount).toBe(1); }); - it('offset 음수면 BadRequestException', async () => { - const ctx = await setupReviewableOrderItem(); - await expect( - service.myReviews(ctx.accountId, { offset: -1 }), - ).rejects.toThrow(BadRequestException); - }); - - it('limit이 50 초과면 BadRequestException', async () => { - const ctx = await setupReviewableOrderItem(); - await expect( - service.myReviews(ctx.accountId, { limit: 51 }), - ).rejects.toThrow(BadRequestException); - }); + // offset/limit 범위 검증은 DTO (MyReviewsInput → UserPaginationInput) 로 이전됨. }); // ─── myReviewForOrderItem ─── diff --git a/src/features/user/services/user-review.service.ts b/src/features/user/services/user-review.service.ts index ebea84a..84c9c6e 100644 --- a/src/features/user/services/user-review.service.ts +++ b/src/features/user/services/user-review.service.ts @@ -8,11 +8,10 @@ import { OrderStatus, ReviewMediaType } from '@prisma/client'; import { parseId } from '@/common/utils/id-parser'; import { USER_REVIEW_ERRORS } from '@/features/user/constants/user-review-error-messages'; +import type { CreateReviewMediaUploadUrlInput } from '@/features/user/dto/inputs/create-review-media-upload-url.input'; +import type { MyReviewsInput } from '@/features/user/dto/inputs/my-reviews.input'; +import type { WriteReviewInput } from '@/features/user/dto/inputs/write-review.input'; import { ReviewRepository } from '@/features/user/repositories/review.repository'; -import type { - CreateReviewMediaUploadUrlInput, - WriteReviewInput, -} from '@/features/user/types/user-review-input.type'; import type { MyReview, MyReviewConnection, @@ -44,12 +43,9 @@ interface ReviewRow { }[]; } -const MIN_CONTENT_LENGTH = 20; -const MAX_CONTENT_LENGTH = 1000; // figma 명세: 사진 최대 10장 / 동영상 1개. 합쳐서 최대 11개까지 허용. const MAX_IMAGE_COUNT = 10; const MAX_VIDEO_COUNT = 1; -const MAX_LIMIT = 50; @Injectable() export class UserReviewService { @@ -62,8 +58,8 @@ export class UserReviewService { accountId: bigint, input: WriteReviewInput, ): Promise { - this.validateRating(input.rating); - this.validateContent(input.content); + // rating · content 길이 검증은 DTO 가 담당. 미디어 카운트(이미지 10 / 영상 1) + // 같은 도메인 invariant 만 service 에서 검증. this.validateMedia(input.media); const orderItemId = parseId(input.orderItemId); @@ -113,18 +109,11 @@ export class UserReviewService { async myReviews( accountId: bigint, - input?: { offset?: number; limit?: number }, + input?: MyReviewsInput, ): Promise { const offset = input?.offset ?? 0; const limit = input?.limit ?? 20; - if (offset < 0) { - throw new BadRequestException('오프셋은 0 이상이어야 합니다.'); - } - if (limit < 1 || limit > MAX_LIMIT) { - throw new BadRequestException('조회 개수는 1~50 사이여야 합니다.'); - } - const { items, totalCount } = await this.reviewRepo.listMyReviews({ accountId, offset, @@ -213,22 +202,6 @@ export class UserReviewService { }); } - private validateRating(rating: number): void { - if (rating < 1 || rating > 5 || (rating * 10) % 5 !== 0) { - throw new BadRequestException(USER_REVIEW_ERRORS.INVALID_RATING); - } - } - - private validateContent(content: string): void { - const trimmed = content.trim(); - if (trimmed.length < MIN_CONTENT_LENGTH) { - throw new BadRequestException(USER_REVIEW_ERRORS.CONTENT_TOO_SHORT); - } - if (trimmed.length > MAX_CONTENT_LENGTH) { - throw new BadRequestException(USER_REVIEW_ERRORS.CONTENT_TOO_LONG); - } - } - private validateMedia( media?: { mediaType: string; mediaUrl: string }[], ): void { diff --git a/src/features/user/services/user-search.service.ts b/src/features/user/services/user-search.service.ts index 038d213..608a36d 100644 --- a/src/features/user/services/user-search.service.ts +++ b/src/features/user/services/user-search.service.ts @@ -1,8 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import type { MySearchHistoriesInput } from '@/features/user/dto/inputs/my-search-histories.input'; import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserBaseService } from '@/features/user/services/user-base.service'; -import type { MySearchHistoriesInput } from '@/features/user/types/user-input.type'; import type { SearchHistoryConnection } from '@/features/user/types/user-output.type'; @Injectable() diff --git a/src/features/user/services/user-wishlist.service.spec.ts b/src/features/user/services/user-wishlist.service.spec.ts index 9667d2b..b949127 100644 --- a/src/features/user/services/user-wishlist.service.spec.ts +++ b/src/features/user/services/user-wishlist.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; import { ProductRepository } from '@/features/product/repositories/product.repository'; @@ -292,18 +292,6 @@ describe('UserWishlistService (real DB)', () => { expect(page2.hasMore).toBe(false); }); - it('offset 음수는 BadRequestException', async () => { - const account = await setupUser(); - await expect( - service.myWishlist(account.id, { offset: -1 }), - ).rejects.toThrow(BadRequestException); - }); - - it('limit이 50 초과면 BadRequestException', async () => { - const account = await setupUser(); - await expect( - service.myWishlist(account.id, { limit: 51 }), - ).rejects.toThrow(BadRequestException); - }); + // offset/limit 범위 검증은 DTO (MyWishlistInput → UserPaginationInput) 로 이전됨. }); }); diff --git a/src/features/user/services/user-wishlist.service.ts b/src/features/user/services/user-wishlist.service.ts index bf51870..18d982c 100644 --- a/src/features/user/services/user-wishlist.service.ts +++ b/src/features/user/services/user-wishlist.service.ts @@ -1,22 +1,13 @@ -import { - BadRequestException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { parseId } from '@/common/utils/id-parser'; import { ProductRepository } from '@/features/product/repositories/product.repository'; import { USER_WISHLIST_ERRORS } from '@/features/user/constants/user-wishlist-error-messages'; -import { - DEFAULT_PAGINATION_LIMIT, - MAX_PAGINATION_LIMIT, -} from '@/features/user/constants/user.constants'; +import { DEFAULT_PAGINATION_LIMIT } from '@/features/user/constants/user.constants'; +import type { MyWishlistInput } from '@/features/user/dto/inputs/my-wishlist.input'; import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserBaseService } from '@/features/user/services/user-base.service'; -import type { - MyWishlistConnection, - MyWishlistInput, -} from '@/features/user/types/user-wishlist-output.type'; +import type { MyWishlistConnection } from '@/features/user/types/user-wishlist-output.type'; @Injectable() export class UserWishlistService extends UserBaseService { @@ -71,13 +62,6 @@ export class UserWishlistService extends UserBaseService { const offset = input?.offset ?? 0; const limit = input?.limit ?? DEFAULT_PAGINATION_LIMIT; - if (offset < 0) { - throw new BadRequestException(USER_WISHLIST_ERRORS.INVALID_OFFSET); - } - if (limit < 1 || limit > MAX_PAGINATION_LIMIT) { - throw new BadRequestException(USER_WISHLIST_ERRORS.INVALID_LIMIT); - } - const { items, totalCount } = await this.repo.findWishlistItems({ accountId, offset, diff --git a/src/features/user/types/user-input.type.ts b/src/features/user/types/user-input.type.ts deleted file mode 100644 index d0edb35..0000000 --- a/src/features/user/types/user-input.type.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface CompleteOnboardingInput { - name?: string | null; - nickname: string; - birthDate?: Date | null; - phoneNumber?: string | null; -} - -export interface UpdateMyProfileInput { - nickname?: string | null; - name?: string | null; - birthDate?: Date | null; - phoneNumber?: string | null; -} - -export interface UpdateMyProfileImageInput { - profileImageUrl: string; -} - -export interface MyNotificationsInput { - unreadOnly?: boolean | null; - offset?: number | null; - limit?: number | null; -} - -export interface MySearchHistoriesInput { - offset?: number | null; - limit?: number | null; -} diff --git a/src/features/user/types/user-order-input.type.ts b/src/features/user/types/user-order-input.type.ts deleted file mode 100644 index 6664a52..0000000 --- a/src/features/user/types/user-order-input.type.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { OrderStatus } from '@prisma/client'; - -export interface MyOrdersInput { - statuses?: OrderStatus[]; - offset?: number; - limit?: number; -} diff --git a/src/features/user/types/user-review-input.type.ts b/src/features/user/types/user-review-input.type.ts deleted file mode 100644 index 0f73361..0000000 --- a/src/features/user/types/user-review-input.type.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface WriteReviewInput { - orderItemId: string; - rating: number; - content: string; - media?: WriteReviewMediaInput[]; -} - -export interface WriteReviewMediaInput { - mediaType: 'IMAGE' | 'VIDEO'; - mediaUrl: string; - thumbnailUrl?: string; - sortOrder: number; -} - -export interface MyReviewsInput { - offset?: number; - limit?: number; -} - -export interface CreateReviewMediaUploadUrlInput { - mediaType: 'IMAGE' | 'VIDEO'; - contentType: string; - contentLength: number; -} diff --git a/src/features/user/types/user-wishlist-output.type.ts b/src/features/user/types/user-wishlist-output.type.ts index 9fe2115..3801dad 100644 --- a/src/features/user/types/user-wishlist-output.type.ts +++ b/src/features/user/types/user-wishlist-output.type.ts @@ -13,8 +13,3 @@ export interface MyWishlistConnection { totalCount: number; hasMore: boolean; } - -export interface MyWishlistInput { - offset?: number | null; - limit?: number | null; -} From 46752ddf135b7708fed29e8cbc6a7dce7237c964 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 24 May 2026 03:29:59 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test(user):=20@Transform=20=EB=B9=84-string?= =?UTF-8?q?=20=EB=B6=84=EA=B8=B0=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit complete-onboarding / update-my-profile / update-my-profile-image DTO 의 @Transform 콜백에서 string 이 아닌 입력(null · 숫자) 분기가 미검사로 남아 branches 임계 86% 미달(85.94%) 원인. 다음 3 케이스 추가: - name 이 null → IsOptional 흡수 후 통과 (Transform 비-string 경로) - name 이 숫자 → IsString 거절 (Transform 통과 후 validator 가 처리) - profileImageUrl 이 null/숫자 → IsString 거절 DTO 입력 파일 전체 branches 100% 달성. 전체 86.1% 로 임계 회복. --- .../dto/inputs/complete-onboarding.input.spec.ts | 14 ++++++++++++++ .../inputs/update-my-profile-image.input.spec.ts | 15 +++++++++++++++ .../dto/inputs/update-my-profile.input.spec.ts | 12 ++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/features/user/dto/inputs/complete-onboarding.input.spec.ts b/src/features/user/dto/inputs/complete-onboarding.input.spec.ts index caae095..977df48 100644 --- a/src/features/user/dto/inputs/complete-onboarding.input.spec.ts +++ b/src/features/user/dto/inputs/complete-onboarding.input.spec.ts @@ -72,4 +72,18 @@ describe('CompleteOnboardingInput', () => { const dto = build({ nickname: 'gildong', name: ' ' }); expect(await validate(dto)).toHaveLength(0); }); + + it('name 이 null 이면 통과 (IsOptional 흡수, Transform 은 비-string 경로)', async () => { + // Transform 콜백은 string 이 아닌 입력을 그대로 통과시켜야 한다. + // 후속 IsOptional 이 null 을 보고 다른 validator 를 스킵. + const dto = build({ nickname: 'gildong', name: null }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('name 이 string 도 null 도 아니면 IsString 으로 거절 (Transform 은 통과)', async () => { + const dto = build({ nickname: 'gildong', name: 12345 }); + const errors = await validate(dto); + expect(errors[0].property).toBe('name'); + expect(errors[0].constraints).toHaveProperty('isString'); + }); }); diff --git a/src/features/user/dto/inputs/update-my-profile-image.input.spec.ts b/src/features/user/dto/inputs/update-my-profile-image.input.spec.ts index ca68334..c2ed91c 100644 --- a/src/features/user/dto/inputs/update-my-profile-image.input.spec.ts +++ b/src/features/user/dto/inputs/update-my-profile-image.input.spec.ts @@ -42,4 +42,19 @@ describe('UpdateMyProfileImageInput', () => { const errors = await validate(dto); expect(errors[0].property).toBe('profileImageUrl'); }); + + it('null 입력 시 IsString 으로 거절 (Transform 은 비-string 경로 통과)', async () => { + // Transform 콜백은 string 이 아닌 값을 그대로 통과시킨다. 검증은 IsString 이 담당. + const dto = build({ profileImageUrl: null }); + const errors = await validate(dto); + expect(errors[0].property).toBe('profileImageUrl'); + expect(errors[0].constraints).toHaveProperty('isString'); + }); + + it('숫자 입력 시 IsString 으로 거절', async () => { + const dto = build({ profileImageUrl: 12345 }); + const errors = await validate(dto); + expect(errors[0].property).toBe('profileImageUrl'); + expect(errors[0].constraints).toHaveProperty('isString'); + }); }); diff --git a/src/features/user/dto/inputs/update-my-profile.input.spec.ts b/src/features/user/dto/inputs/update-my-profile.input.spec.ts index 74eebcb..bf84989 100644 --- a/src/features/user/dto/inputs/update-my-profile.input.spec.ts +++ b/src/features/user/dto/inputs/update-my-profile.input.spec.ts @@ -38,4 +38,16 @@ describe('UpdateMyProfileInput', () => { const errors = await validate(dto); expect(errors[0].property).toBe('phoneNumber'); }); + + it('name 이 null 이면 통과 (IsOptional 흡수, Transform 은 비-string 경로)', async () => { + const dto = build({ name: null }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('name 이 string 도 null 도 아니면 IsString 으로 거절', async () => { + const dto = build({ name: 12345 }); + const errors = await validate(dto); + expect(errors[0].property).toBe('name'); + expect(errors[0].constraints).toHaveProperty('isString'); + }); }); From 82b92eca1c6408b2087ef881e1838ccfe7094ab4 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 24 May 2026 03:30:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore(ci):=20yarn=20validate=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20+=20Husky=20pre-push=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit push 전에 lint / tsc / dto:check / test:cov(커버리지 임계 포함) 를 일괄 검증해 CI 사이클 소모를 사전 차단한다. - package.json scripts: validate = lint && tsc && dto:check && test:cov - .husky/pre-push: yarn validate 호출 - README: 명령 표에 validate / dto:check 추가 - 긴급 우회: git push --no-verify - testcontainers 의존성상 Docker 가 동작 중이어야 함 --- .husky/pre-push | 6 ++++++ README.md | 4 +++- package.json | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100755 .husky/pre-push diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..5f82406 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +# Push 전 로컬 검증. +# - lint / type-check / dto:check / test:cov(커버리지 임계 포함) 일괄 실행 +# - 통과해야 push 진행. 긴급 시 `git push --no-verify` 로 우회 가능 +# - Docker(testcontainers) 가 동작 중이어야 한다 +yarn validate diff --git a/README.md b/README.md index 6325ec2..399441a 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,9 @@ yarn start:dev | `yarn build` | 프로덕션 빌드 (`dist/`) | | `yarn lint` | ESLint --fix | | `yarn test` | Jest (실 DB 통합 테스트 포함) | -| `yarn test:cov` | 커버리지 측정 | +| `yarn test:cov` | 커버리지 측정 (임계 미달 시 비-0 종료) | +| `yarn dto:check` | SDL ↔ DTO 동기화 검사 (마이그레이션 중 warning 모드) | +| `yarn validate` | lint + tsc + dto:check + test:cov 일괄. push 전 권장 | | `yarn prisma:migrate:dev` | DB 마이그레이션 생성/적용 | | `yarn prisma:studio` | Prisma Studio (GUI DB 브라우저) | | `yarn graphql:codegen` | SDL → TypeScript 타입 생성 | diff --git a/package.json b/package.json index 9792edf..31a4c2e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "graphql:codegen": "graphql-codegen --config codegen.yml", "graphql:docs": "spectaql -c spectaql.yml", "dto:check": "ts-node -r tsconfig-paths/register scripts/check-graphql-dto-sync.ts", + "validate": "yarn lint && npx tsc --noEmit && yarn dto:check && yarn test:cov", "prepare": "husky" }, "prisma": {