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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 타입 생성 |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
89 changes: 89 additions & 0 deletions src/features/user/dto/inputs/complete-onboarding.input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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);
});

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');
});
});
52 changes: 52 additions & 0 deletions src/features/user/dto/inputs/complete-onboarding.input.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions src/features/user/dto/inputs/my-notifications.input.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
9 changes: 9 additions & 0 deletions src/features/user/dto/inputs/my-notifications.input.ts
Original file line number Diff line number Diff line change
@@ -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;
}
56 changes: 56 additions & 0 deletions src/features/user/dto/inputs/my-orders.input.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
11 changes: 11 additions & 0 deletions src/features/user/dto/inputs/my-orders.input.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input';

export class MyRecentViewedProductsInput extends UserPaginationInput {}
3 changes: 3 additions & 0 deletions src/features/user/dto/inputs/my-reviews.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input';

export class MyReviewsInput extends UserPaginationInput {}
Loading
Loading