From ff055d7d4498baeeddbf00f01d0d0085e0301667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A7=80=ED=9B=88?= Date: Sat, 7 Jun 2025 16:37:46 +0900 Subject: [PATCH] =?UTF-8?q?TEST=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.e2e.yml | 31 +- init.sql | 1 + jest.config.ts | 12 + package.json | 6 +- src/app.module.ts | 2 - src/auth/auth.module.ts | 4 +- src/auth/entities/auth._entity.ts | 10 +- .../entities/email-verification.entity.ts | 2 +- src/auth/inputs/apple-login.input.ts | 16 +- src/auth/inputs/auth.input.ts | 4 +- src/auth/inputs/kakao-login.input.ts | 12 +- src/auth/inputs/naver-login.input.ts | 12 +- src/auth/inputs/social-login.input.ts | 38 +- .../email-verification.repository.ts | 2 +- src/auth/services/auth-mail.service.spec.ts | 48 +++ ...-mail.serivice.ts => auth-mail.service.ts} | 0 .../auth-verification.service.spec.ts | 111 +++++ .../services/auth-verification.service.ts | 2 +- src/auth/services/auth.service.spec.ts | 189 +++++++++ src/common/config/graphql-config.service.ts | 12 +- src/common/config/ormconfig.ts | 6 +- .../1748951601567-CreateInitialTables.ts | 2 + .../chat/chatgpt-daily-text.service.spec.ts | 122 ++++++ .../chat/chatgpt-daily-text.service.ts | 2 +- src/graphql-schema.gql | 316 +++++++++++++- src/health/health.controller.ts | 1 - src/health/health.module.ts | 1 - src/health/health.service.ts | 7 +- src/main.ts | 10 +- src/resume/dto/create-user-profile.input.ts | 42 ++ .../resume-user-daily-limit.respository.ts | 2 +- .../resume-user-download-limit-repository.ts | 2 +- .../services/resume-daily-log-service.spec.ts | 155 +++++++ .../services/resume-daily-log.service.ts | 4 +- .../resume-project-daily-log.service.spec.ts | 86 ++++ .../resume-project-daily-log.service.ts | 1 - .../resume-user-activity.service.spec.ts | 115 +++++ .../services/resume-user-activity.service.ts | 3 +- .../resume-user-career.service.spec.ts | 139 +++++++ .../services/resume-user-career.service.ts | 7 +- .../resume-user-certification.service.spec.ts | 122 ++++++ .../resume-user-certification.service.ts | 5 +- .../resume-user-daily-limit.service.spec.ts | 112 +++++ .../resume-user-daily-limit.service.ts | 2 +- ...resume-user-download-limit.service.spec.ts | 87 ++++ .../resume-user-download-limit.service.ts | 2 +- .../resume-user-education.service.spec.ts | 122 ++++++ .../services/resume-user-education.service.ts | 3 +- .../resume-user-profile.service.spec.ts | 126 ++++++ .../services/resume-user-profile.service.ts | 6 +- .../resume-user-project.service.spec.ts | 118 ++++++ src/upload/upload.module.ts | 20 +- src/upload/upload.resolver.ts | 106 ++--- src/upload/upload.service.ts | 232 +++++------ src/user/entities/user.entity.ts | 6 +- src/user/inputs/user.input.ts | 6 +- src/user/user.module.integration.spec.ts | 257 ------------ src/user/user.resolver.spec.ts | 101 ----- src/user/user.service.spec.ts | 140 ++++--- src/user/user.service.ts | 2 +- test/app.e2e-spec.ts | 392 +++++++++++++----- test/jest-e2e.json | 17 +- 62 files changed, 2697 insertions(+), 824 deletions(-) create mode 100644 init.sql create mode 100644 jest.config.ts create mode 100644 src/auth/services/auth-mail.service.spec.ts rename src/auth/services/{auth-mail.serivice.ts => auth-mail.service.ts} (100%) create mode 100644 src/auth/services/auth-verification.service.spec.ts create mode 100644 src/auth/services/auth.service.spec.ts create mode 100644 src/common/external/chat/chatgpt-daily-text.service.spec.ts create mode 100644 src/resume/dto/create-user-profile.input.ts create mode 100644 src/resume/services/resume-daily-log-service.spec.ts create mode 100644 src/resume/services/resume-project-daily-log.service.spec.ts create mode 100644 src/resume/services/resume-user-activity.service.spec.ts create mode 100644 src/resume/services/resume-user-career.service.spec.ts create mode 100644 src/resume/services/resume-user-certification.service.spec.ts create mode 100644 src/resume/services/resume-user-daily-limit.service.spec.ts create mode 100644 src/resume/services/resume-user-download-limit.service.spec.ts create mode 100644 src/resume/services/resume-user-education.service.spec.ts create mode 100644 src/resume/services/resume-user-profile.service.spec.ts create mode 100644 src/resume/services/resume-user-project.service.spec.ts delete mode 100644 src/user/user.module.integration.spec.ts delete mode 100644 src/user/user.resolver.spec.ts diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index ae7ed65..1fb6ac7 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -1,31 +1,40 @@ -version: '3' services: app: - container_name: nestjs_boilerplate + container_name: nestjs_test build: . ports: - - '${PORT}:${PORT}' + - 3000:3000 depends_on: - - postgres + test_postgres: + condition: service_healthy volumes: - .:/app - node_modules:/app/node_modules - command: npm run test:e2e + command: > + sh -c " + npm run test:e2e + " + env_file: + - .test.env - postgres: + test_postgres: image: postgres - container_name: postgres + container_name: test_postgres + env_file: + - .test.env healthcheck: test: ['CMD', 'pg_isready', '-U', 'postgres'] restart: unless-stopped environment: - POSTGRES_USER: ${DB_USER} - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test TZ: 'UTC' PGTZ: 'UTC' ports: - - '5432:5432' + - '5433:5432' + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql volumes: node_modules: diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..d159cc5 --- /dev/null +++ b/init.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..8eef7cc --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,12 @@ +// jest.config.ts +export default { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', +}; diff --git a/package.json b/package.json index 409e9d0..11256e0 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,14 @@ "start:dev": "export NODE_ENV=development && nest start --watch", "lint": "eslint \"{src,test}/**/*.ts\" --fix", "lint:fix": "eslint -c ./eslint.config.mjs \"{src,test}/**/*.ts\" --fix", - "test": "jest", + "test": "jest --config jest.config.ts", "test:unit": "jest --testPathPattern='^(?!.*\\.integration\\.spec\\.ts$).*\\.spec\\.ts$'", "test:integration": "jest --testPathPattern='\\.integration\\.spec\\.ts$'", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "rm -rf dist && jest --config ./test/jest-e2e.json", - "test:e2e:docker": "rm -rf dist && docker-compose -f docker-compose.e2e.yml --env-file ./.test.env up --exit-code-from app", + "test:e2e": "npm run build && jest --config ./test/jest-e2e.json", + "test:e2e:docker": "docker-compose -f docker-compose.e2e.yml --env-file ./.test.env up --build --exit-code-from app", "g": "plop --plopfile ./generator/plopfile.mjs", "prepare": "husky", "typeorm": "export NODE_ENV=production&& ts-node ./node_modules/typeorm/cli.js -d ./src/common/config/ormconfig.ts", diff --git a/src/app.module.ts b/src/app.module.ts index fe3e3ea..8176d36 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,7 +13,6 @@ import { envValidation } from './common/helper/env.validation'; import { LoggingModule } from './common/logging/logging.module'; import { HealthModule } from './health/health.module'; import { ResumeModule } from './resume/resume.module'; -import { UploadModule } from './upload/upload.module'; import { UserModule } from './user/user.module'; @Module({ @@ -34,7 +33,6 @@ import { UserModule } from './user/user.module'; }), UserModule, AuthModule, - UploadModule, HealthModule, CustomCacheModule.forRoot(), LoggingModule, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 2d9ca77..34040e2 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -12,10 +12,9 @@ import { UserModule } from '../user/user.module'; import { EmailVerificationRepository } from './repositories/email-verification.repository'; import { AuthVerificationResolver } from './resolvers/auth-verification.resolver'; import { AuthResolver } from './resolvers/auth.resolver'; -import { AuthMailService } from './services/auth-mail.serivice'; +import { AuthMailService } from './services/auth-mail.service'; import { AuthVerificationService } from './services/auth-verification.service'; import { AuthService } from './services/auth.service'; -// import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; @@ -44,7 +43,6 @@ import { LocalStrategy } from './strategies/local.strategy'; AuthService, AuthResolver, // 이메일/비밀번호 로그인 JwtStrategy, - // JwtRefreshStrategy, LocalStrategy, AuthMailService, AuthVerificationService, diff --git a/src/auth/entities/auth._entity.ts b/src/auth/entities/auth._entity.ts index 585a21d..23c30f1 100644 --- a/src/auth/entities/auth._entity.ts +++ b/src/auth/entities/auth._entity.ts @@ -1,7 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; -@ObjectType() -export class UserResponse { +@ObjectType('AuthUserResponse') +export class AuthUserResponse { @Field(() => String) id: string; @@ -12,11 +12,11 @@ export class UserResponse { username: string; } -@ObjectType() +@ObjectType('JwtWithUser') export class JwtWithUser { @Field(() => String) jwt: string; - @Field(() => UserResponse) - user: UserResponse; + @Field(() => AuthUserResponse) + user: AuthUserResponse; } diff --git a/src/auth/entities/email-verification.entity.ts b/src/auth/entities/email-verification.entity.ts index 1369ae6..b2c1641 100644 --- a/src/auth/entities/email-verification.entity.ts +++ b/src/auth/entities/email-verification.entity.ts @@ -3,7 +3,7 @@ import { Field, ID, ObjectType } from '@nestjs/graphql'; import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; import { ulid } from 'ulid'; -@ObjectType() +@ObjectType('EmailVerification') @Entity('email_verifications') export class EmailVerification { @Field(() => ID) diff --git a/src/auth/inputs/apple-login.input.ts b/src/auth/inputs/apple-login.input.ts index 3cb5426..5fe24d2 100644 --- a/src/auth/inputs/apple-login.input.ts +++ b/src/auth/inputs/apple-login.input.ts @@ -1,10 +1,10 @@ -import { Field, InputType } from '@nestjs/graphql'; +// import { Field, InputType } from '@nestjs/graphql'; -@InputType() -export class AppleLoginInput { - @Field() - idToken: string; +// @InputType() +// export class AppleLoginInput { +// @Field() +// idToken: string; - @Field({ nullable: true }) - user?: string; // Apple에서 name 정보는 처음 로그인 시에만 전달됨 -} +// @Field({ nullable: true }) +// user?: string; // Apple에서 name 정보는 처음 로그인 시에만 전달됨 +// } diff --git a/src/auth/inputs/auth.input.ts b/src/auth/inputs/auth.input.ts index 0dd9aa2..7f04993 100644 --- a/src/auth/inputs/auth.input.ts +++ b/src/auth/inputs/auth.input.ts @@ -2,7 +2,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { IsEmail, IsNotEmpty, Matches, MinLength } from 'class-validator'; -@InputType() +@InputType('SignInInputType', { isAbstract: true }) export class SignInInput { @Field(() => String) @IsNotEmpty() @@ -13,7 +13,7 @@ export class SignInInput { password?: string; } -@InputType() +@InputType('SignUpInputType', { isAbstract: true }) export class SignUpInput { @Field(() => String) @IsNotEmpty() diff --git a/src/auth/inputs/kakao-login.input.ts b/src/auth/inputs/kakao-login.input.ts index cc6eb4c..c869bda 100644 --- a/src/auth/inputs/kakao-login.input.ts +++ b/src/auth/inputs/kakao-login.input.ts @@ -1,7 +1,7 @@ -import { Field, InputType } from '@nestjs/graphql'; +// import { Field, InputType } from '@nestjs/graphql'; -@InputType() -export class KakaoLoginInput { - @Field() - accessToken: string; -} +// @InputType() +// export class KakaoLoginInput { +// @Field() +// accessToken: string; +// } diff --git a/src/auth/inputs/naver-login.input.ts b/src/auth/inputs/naver-login.input.ts index f62e453..e75b881 100644 --- a/src/auth/inputs/naver-login.input.ts +++ b/src/auth/inputs/naver-login.input.ts @@ -1,7 +1,7 @@ -import { Field, InputType } from '@nestjs/graphql'; +// import { Field, InputType } from '@nestjs/graphql'; -@InputType() -export class NaverLoginInput { - @Field() - accessToken: string; -} +// @InputType() +// export class NaverLoginInput { +// @Field() +// accessToken: string; +// } diff --git a/src/auth/inputs/social-login.input.ts b/src/auth/inputs/social-login.input.ts index 6b77b7b..8d2136f 100644 --- a/src/auth/inputs/social-login.input.ts +++ b/src/auth/inputs/social-login.input.ts @@ -1,25 +1,25 @@ -import { Field, InputType } from '@nestjs/graphql'; +// import { Field, InputType } from '@nestjs/graphql'; -import { IsEmail, IsEnum, IsString } from 'class-validator'; +// import { IsEmail, IsEnum, IsString } from 'class-validator'; -import { SocialProvider } from '../../user/enum/social-provider.enum'; +// import { SocialProvider } from '../../user/enum/social-provider.enum'; -@InputType() -export class SocialLoginInput { - @Field(() => SocialProvider) - @IsEnum(SocialProvider) - provider: SocialProvider; +// @InputType() +// export class SocialLoginInput { +// @Field(() => SocialProvider) +// @IsEnum(SocialProvider) +// provider: SocialProvider; - @Field(() => String) - @IsString() - providerId: string; +// @Field(() => String) +// @IsString() +// providerId: string; - @Field(() => String, { nullable: true }) - @IsString() - @IsEmail() - email?: string; +// @Field(() => String, { nullable: true }) +// @IsString() +// @IsEmail() +// email?: string; - @Field(() => String, { nullable: true }) - @IsString() - nickname?: string; -} +// @Field(() => String, { nullable: true }) +// @IsString() +// nickname?: string; +// } diff --git a/src/auth/repositories/email-verification.repository.ts b/src/auth/repositories/email-verification.repository.ts index d9a63cd..d364dfd 100644 --- a/src/auth/repositories/email-verification.repository.ts +++ b/src/auth/repositories/email-verification.repository.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs'; +import * as dayjs from 'dayjs'; import { EntityManager } from 'typeorm'; import { CustomRepository } from '../../common/decorators/typeorm.decorator'; diff --git a/src/auth/services/auth-mail.service.spec.ts b/src/auth/services/auth-mail.service.spec.ts new file mode 100644 index 0000000..782b35c --- /dev/null +++ b/src/auth/services/auth-mail.service.spec.ts @@ -0,0 +1,48 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { MailerService } from '@nestjs-modules/mailer'; + +import { AuthMailService } from './auth-mail.service'; + +describe('AuthMailService', () => { + let service: AuthMailService; + let mailerService: MailerService; + + const mockMailerService = { + sendMail: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthMailService, + { + provide: MailerService, + useValue: mockMailerService, + }, + ], + }).compile(); + + service = module.get(AuthMailService); + mailerService = module.get(MailerService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should send verification code to email', async () => { + const email = 'test@example.com'; + const code = '123456'; + + await service.sendVerificationCode(email, code); + + expect(mailerService.sendMail).toHaveBeenCalledTimes(1); + expect(mailerService.sendMail).toHaveBeenCalledWith({ + to: email, + subject: '[ResumeLog] 이메일 인증 코드', + template: 'verify-email', + context: { code }, + }); + }); +}); diff --git a/src/auth/services/auth-mail.serivice.ts b/src/auth/services/auth-mail.service.ts similarity index 100% rename from src/auth/services/auth-mail.serivice.ts rename to src/auth/services/auth-mail.service.ts diff --git a/src/auth/services/auth-verification.service.spec.ts b/src/auth/services/auth-verification.service.spec.ts new file mode 100644 index 0000000..426393e --- /dev/null +++ b/src/auth/services/auth-verification.service.spec.ts @@ -0,0 +1,111 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { TransactionService } from '../../common/transaction/transaction.service'; +import { EmailVerificationRepository } from '../repositories/email-verification.repository'; +import { AuthMailService } from './auth-mail.service'; +import { AuthVerificationService } from './auth-verification.service'; + +describe('AuthVerificationService', () => { + let service: AuthVerificationService; + + const mockEmailRepo = { + createCode: jest.fn(), + verifyCode: jest.fn(), + isVerified: jest.fn(), + }; + + const mockMailService = { + sendVerificationCode: jest.fn(), + }; + + const mockTransactionService = { + runInTransaction: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthVerificationService, + { + provide: EmailVerificationRepository, + useValue: mockEmailRepo, + }, + { + provide: AuthMailService, + useValue: mockMailService, + }, + { + provide: TransactionService, + useValue: mockTransactionService, + }, + ], + }).compile(); + + service = module.get(AuthVerificationService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should send verification email with generated code', async () => { + mockEmailRepo.createCode.mockResolvedValue(undefined); + mockMailService.sendVerificationCode.mockResolvedValue(undefined); + + const result = await service.request('test1234@gmail.com'); + + expect(mockEmailRepo.createCode).toHaveBeenCalledTimes(1); + expect(mockMailService.sendVerificationCode).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + }); + + it('should return true when code is valid', async () => { + mockTransactionService.runInTransaction.mockImplementation( + async (callback) => { + return callback({}); + }, + ); + + mockEmailRepo.verifyCode.mockResolvedValue(true); + + const result = await service.verify('test1234@gmail.com', '123456'); + + expect(mockEmailRepo.verifyCode).toHaveBeenCalledWith( + 'test1234@gmail.com', + '123456', + {}, + ); + expect(result).toBe(true); + }); + + it('should throw BadRequestException for invalid code', async () => { + mockTransactionService.runInTransaction.mockImplementation( + async (callback) => { + return callback({}); + }, + ); + + mockEmailRepo.verifyCode.mockResolvedValue(false); + + await expect( + service.verify('test1234@gmail.com', 'wrongcode'), + ).rejects.toThrow(BadRequestException); + }); + + it('should pass if email is verified', async () => { + mockEmailRepo.isVerified.mockResolvedValue(true); + + await expect( + service.assertVerified('test1234@gmail.com'), + ).resolves.not.toThrow(); + }); + + it('should throw if email is not verified', async () => { + mockEmailRepo.isVerified.mockResolvedValue(false); + + await expect(service.assertVerified('test1234@gmail.com')).rejects.toThrow( + BadRequestException, + ); + }); +}); diff --git a/src/auth/services/auth-verification.service.ts b/src/auth/services/auth-verification.service.ts index 4aa9167..07e3f15 100644 --- a/src/auth/services/auth-verification.service.ts +++ b/src/auth/services/auth-verification.service.ts @@ -3,7 +3,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { TransactionService } from '../../common/transaction/transaction.service'; import { EmailVerificationRepository } from '../repositories/email-verification.repository'; -import { AuthMailService } from './auth-mail.serivice'; +import { AuthMailService } from './auth-mail.service'; @Injectable() export class AuthVerificationService { diff --git a/src/auth/services/auth.service.spec.ts b/src/auth/services/auth.service.spec.ts new file mode 100644 index 0000000..6dc28df --- /dev/null +++ b/src/auth/services/auth.service.spec.ts @@ -0,0 +1,189 @@ +import { BadRequestException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; + +import * as bcrypt from 'bcrypt'; + +import { TransactionService } from '../../common/transaction/transaction.service'; +import { UtilService } from '../../common/util/util.service'; +import { UserService } from '../../user/user.service'; +import { SignInInput, SignUpInput } from '../inputs/auth.input'; +import { AuthVerificationService } from './auth-verification.service'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + const mockUserService = { + findByEmail: jest.fn(), + create: jest.fn(), + }; + + const mockJwtService = { + sign: jest.fn().mockReturnValue('signed-jwt-token'), + }; + + const mockUtilService = { + pick: jest.fn().mockImplementation((user, keys) => { + return keys.reduce((obj, key) => ({ ...obj, [key]: user[key] }), {}); + }), + }; + + const mockTransactionService = { + runInTransaction: jest.fn(), + }; + + const mockAuthVerificationService = { + assertVerified: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: UserService, useValue: mockUserService }, + { provide: JwtService, useValue: mockJwtService }, + { provide: UtilService, useValue: mockUtilService }, + { provide: TransactionService, useValue: mockTransactionService }, + { + provide: AuthVerificationService, + useValue: mockAuthVerificationService, + }, + ], + }).compile(); + + service = module.get(AuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should generate access token using jwtService and utilService', () => { + const user = { id: '123', role: 'user' }; + const token = service.generateAccessToken(user as any); + expect(mockUtilService.pick).toHaveBeenCalledWith(user, ['id', 'role']); + expect(mockJwtService.sign).toHaveBeenCalledWith({ + id: '123', + role: 'user', + }); + expect(token).toBe('signed-jwt-token'); + }); + + it('should sign up new user after verification', async () => { + const input: SignUpInput = { + email: 'new@example.com', + password: 'pass', + username: 'newuser', + confirmPassword: 'pass', + termsAgreed: true, + }; + + const createdUser = { + id: 'u123', + email: input.email, + username: input.username, + role: 'user', + }; + + mockUserService.findByEmail.mockResolvedValue(null); + mockAuthVerificationService.assertVerified.mockResolvedValue(true); + mockUserService.create.mockResolvedValue(createdUser); + + mockTransactionService.runInTransaction.mockImplementation( + async (callback) => { + return callback({}); + }, + ); + + const result = await service.signUp(input); + expect(result).toEqual({ + jwt: 'signed-jwt-token', + user: { + id: 'u123', + email: input.email, + username: input.username, + }, + }); + }); + + it('should throw if email already exists during signup', async () => { + const input: SignUpInput = { + email: 'exist@example.com', + password: 'pass', + username: 'existing', + confirmPassword: 'pass', + termsAgreed: true, + }; + mockUserService.findByEmail.mockResolvedValue({ id: 'exists' }); + + mockTransactionService.runInTransaction.mockImplementation(async (cb) => + cb({}), + ); + + await expect(service.signUp(input)).rejects.toThrow(BadRequestException); + }); + + it('should return jwt and user on signIn', async () => { + const user = { + id: 'u123', + email: 'test@example.com', + username: 'tester', + }; + const result = await service.signIn(user as any); + expect(result).toEqual({ + jwt: 'signed-jwt-token', + user: { + id: 'u123', + email: 'test@example.com', + username: 'tester', + }, + }); + }); + + it('should validate user with correct password', async () => { + const input: SignInInput = { + email: 'test@example.com', + password: '1234', + }; + const user = { + id: 'u1', + email: input.email, + password: await bcrypt.hash(input.password, 10), + }; + mockUserService.findByEmail.mockResolvedValue(user); + jest + .spyOn(bcrypt, 'compare') + .mockImplementation(() => Promise.resolve(true)); + + const result = await service.validateUser(input); + expect(result).toEqual(user); + }); + + it('should return null if user not found', async () => { + mockUserService.findByEmail.mockResolvedValue(null); + const result = await service.validateUser({ + email: 'notfound@example.com', + password: '1234', + }); + expect(result).toBeNull(); + }); + + it('should return null if password is invalid', async () => { + const input: SignInInput = { + email: 'wrongpass@example.com', + password: 'wrong', + }; + const user = { + email: input.email, + password: await bcrypt.hash('correct', 10), + }; + mockUserService.findByEmail.mockResolvedValue(user); + jest + .spyOn(bcrypt, 'compare') + .mockImplementation(() => Promise.resolve(false)); + + const result = await service.validateUser(input); + expect(result).toBeNull(); + }); +}); diff --git a/src/common/config/graphql-config.service.ts b/src/common/config/graphql-config.service.ts index 01f5ad9..2947190 100644 --- a/src/common/config/graphql-config.service.ts +++ b/src/common/config/graphql-config.service.ts @@ -21,10 +21,14 @@ export class GraphqlConfigService const isDev = ['development', 'test', 'local'].includes(nodeEnv); return { - autoSchemaFile: join( - cwd(), - isDev ? 'test/graphql-schema.gql' : 'src/graphql-schema.gql', - ), + // autoSchemaFile: join( + // cwd(), + // isDev ? 'test/graphql-schema.gql' : 'src/graphql-schema.gql', + // ), + autoSchemaFile: + nodeEnv === 'test' + ? true // 메모리에만 스키마, 파일 저장 안 함 → 타입 중복 방지 + : join(cwd(), 'src/graphql-schema.gql'), // dev/production 공통 파일 sortSchema: true, cache: 'bounded', context: ({ req }) => ({ req }), diff --git a/src/common/config/ormconfig.ts b/src/common/config/ormconfig.ts index bb8b7fd..d9fe448 100644 --- a/src/common/config/ormconfig.ts +++ b/src/common/config/ormconfig.ts @@ -37,7 +37,7 @@ export const setTypeormConfig = ( database: getConfigValue('DB_NAME'), // 환경별 설정 - dropSchema: getConfigValue('NODE_ENV') === 'test', // 테스트 환경에서만 스키마 삭제 + dropSchema: false, // 테스트 환경에서만 스키마 삭제 // entities: [join(cwd(), 'dist', '**', '*.entity.js')], entities: [join(cwd(), 'dist', '**', '*.entity.js')], @@ -48,8 +48,8 @@ export const setTypeormConfig = ( // migration 디렉토리 설정 (migration 생성 시 테스트 확인용) // migrationsTableName: 'typeorm_migrations', - synchronize: isTsRuntime, - migrationsRun: false, + synchronize: false, + migrationsRun: false, // 테스트 환경에서도 마이그레이션 자동 실행 logging: getConfigValue('NODE_ENV') === 'development' ? true : false, namingStrategy: new SnakeNamingStrategy(), }; diff --git a/src/common/database/migrations/1748951601567-CreateInitialTables.ts b/src/common/database/migrations/1748951601567-CreateInitialTables.ts index 6848a0b..fb3ffb5 100644 --- a/src/common/database/migrations/1748951601567-CreateInitialTables.ts +++ b/src/common/database/migrations/1748951601567-CreateInitialTables.ts @@ -4,6 +4,8 @@ export class CreateInitialTables1748951601567 implements MigrationInterface { name = 'CreateInitialTables1748951601567'; public async up(queryRunner: QueryRunner): Promise { + // await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + await queryRunner.query( `CREATE TYPE "public"."user_provider_enum" AS ENUM('local', 'apple', 'kakao', 'naver')`, ); diff --git a/src/common/external/chat/chatgpt-daily-text.service.spec.ts b/src/common/external/chat/chatgpt-daily-text.service.spec.ts new file mode 100644 index 0000000..2462be9 --- /dev/null +++ b/src/common/external/chat/chatgpt-daily-text.service.spec.ts @@ -0,0 +1,122 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { ChatGPTDailyTextService } from './chatgpt-daily-text.service'; + +describe('ChatGPTDailyTextService', () => { + let service: ChatGPTDailyTextService; + + const mockFetch = jest.fn(); + global.fetch = mockFetch; + + const mockConfigService = { + get: jest.fn().mockReturnValue('mock-api-key'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ChatGPTDailyTextService, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(ChatGPTDailyTextService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should send a valid request to OpenAI and return parsed content', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + choices: [ + { + message: { content: '"성과를 이룬 프로젝트 진행"' }, + }, + ], + }), + }); + + const result = await service.requestChatCompletion('Test prompt'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/chat/completions', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer mock-api-key', + }), + }), + ); + expect(result).toBe('성과를 이룬 프로젝트 진행'); + }); + + it('should throw an error if response is not ok', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'Failure', + }); + + await expect(service.requestChatCompletion('Bad prompt')).rejects.toThrow( + /OpenAI API Error: 500 Internal Server Error/, + ); + }); + + it('should throw an error if content is empty', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: ' ' } }] }), + }); + + await expect( + service.requestChatCompletion('Empty content'), + ).rejects.toThrow(/OpenAI 응답 content가 비어/); + }); + + it('should generate summary from text', async () => { + const spy = jest + .spyOn(service, 'requestChatCompletion') + .mockResolvedValue('결과 중심 요약'); + + const result = await service.generateSummary('작업내용'); + expect(result).toBe('결과 중심 요약'); + expect(spy).toHaveBeenCalled(); + }); + + it('should generate daily title from text', async () => { + const spy = jest + .spyOn(service, 'requestChatCompletion') + .mockResolvedValue('개발'); + + const result = await service.generateDailyTitleFromText('API 개발 작업'); + expect(result).toBe('개발'); + expect(spy).toHaveBeenCalled(); + }); + + it('should pick content for resume with correct format', async () => { + const spy = jest + .spyOn(service, 'requestChatCompletion') + .mockResolvedValue(`[{"logId":"abc123","summary":"요약입니다"}]`); + + const result = await service.pickContentForResume({ + company: '네이버', + resumeStyle: '백엔드', + logs: [ + { + projectName: '로그인 개선', + logContent: '로그인 속도를 50% 향상시킴', + logTag: '백엔드', + logArea: '인증', + logId: 'abc123', + }, + ], + }); + + expect(result).toContain('"logId":"abc123"'); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/src/common/external/chat/chatgpt-daily-text.service.ts b/src/common/external/chat/chatgpt-daily-text.service.ts index 7f48fa7..bfa2059 100644 --- a/src/common/external/chat/chatgpt-daily-text.service.ts +++ b/src/common/external/chat/chatgpt-daily-text.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ExceptionError } from 'src/common/format/exceptions'; +import { ExceptionError } from '../../../common/format/exceptions'; @Injectable() export class ChatGPTDailyTextService { diff --git a/src/graphql-schema.gql b/src/graphql-schema.gql index da33655..6c81cee 100644 --- a/src/graphql-schema.gql +++ b/src/graphql-schema.gql @@ -2,6 +2,25 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +type Activity { + createdAt: DateTime! + description: String + endDate: DateTime + id: ID! + organization: String + startDate: DateTime! + title: String! + updatedAt: DateTime! + user: User! + userId: ID! +} + +type AuthUserResponse { + email: String! + id: String! + username: String! +} + type Career { companyName: String! createdAt: DateTime! @@ -16,7 +35,78 @@ type Career { userId: ID } -input CreateUserInput { +type Certificate { + createdAt: DateTime! + expiryDate: DateTime + id: ID! + issuedDate: DateTime! + issuer: String + name: String! + updatedAt: DateTime! + user: User! + userId: ID! +} + +input CreateActivityInput { + description: String + endDate: DateTime + organization: String + startDate: DateTime! + title: String! +} + +input CreateCareerInput { + companyName: String! + endDate: DateTime + industry: IndustryType! + position: WorkArea! + startDate: DateTime! + summary: String + userId: ID! +} + +input CreateCertificateInput { + expiryDate: DateTime + issuedDate: DateTime! + issuer: String + name: String + userId: String! +} + +input CreateDailyLogInput { + area: WorkArea! + careerId: ID + content: String! + projectId: ID + tag: DailyTag! +} + +input CreateDailyLogUsageInput { + type: DailyLogUsageType! +} + +input CreateEducationInput { + degree: String + endDate: DateTime + major: String + schoolName: String! + startDate: DateTime! + userId: String! +} + +input CreateProjectInput { + careerId: ID + description: String! + endDate: DateTime + name: String! + responsibility: String + startDate: DateTime! + techStack: [String!] + type: ProjectType! + userId: ID! +} + +input CreateUserInputType { email: String! nickname: String! password: String! @@ -27,11 +117,75 @@ input CreateUserInput { username: String! } +input CreateUserProfileInput { + blog: String + email: String + github: String + name: String + phone: String +} + +type DailyLog { + area: WorkArea + career: Career + careerId: ID + content: String! + createdAt: DateTime! + id: ID! + project: Project + projectId: ID + tag: DailyTag + updatedAt: DateTime! + user: User + userId: ID! +} + +type DailyLogUsage { + createdAt: DateTime! + id: ID! + type: DailyLogUsageType! + user: User! + userId: ID! +} + +enum DailyLogUsageType { + AREA + SUMMARY + TAG + TITLE +} + +enum DailyTag { + DEBUG + DEPLOYMENT + DEVELOPMENT + DOCUMENTATION + MEETING + PLANNING + REFACTORING + REVIEW + STUDY + TESTING +} + """ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. """ scalar DateTime +type Education { + createdAt: DateTime! + degree: String! + endDate: DateTime + id: ID! + major: String! + schoolName: String! + startDate: DateTime! + updatedAt: DateTime! + user: User! + userId: ID! +} + input GetManyInput { """ {key: "ASC" or "DESC" or "asc" or "desc" or 1 or -1} or {key: {direction: "ASC" or "DESC" or "asc" or "desc", nulls: "first" or "last" or "FIRST" or "LAST"}}} @@ -79,21 +233,39 @@ scalar JSON type JwtWithUser { jwt: String! - user: User! + user: AuthUserResponse! } type Mutation { - createUser(input: CreateUserInput!): User! - deleteFiles(keys: [String!]!): Boolean! + createActivity(input: CreateActivityInput!): Activity! + createCareer(input: CreateCareerInput!): Career! + createCertificate(input: CreateCertificateInput!): Certificate! + createDailyLog(input: CreateDailyLogInput!): DailyLog! + createDailyLogUsage(input: CreateDailyLogUsageInput!): DailyLogUsage! + createEducation(input: CreateEducationInput!): Education! + createProject(input: CreateProjectInput!): Project! + createResumeDownload: ResumeDownload! + createUser(input: CreateUserInputType!): User! + createUserProfile(input: CreateUserProfileInput!): UserProfile! + deleteActivity(id: String!): Activity! + deleteCareer(id: String!): Career! + deleteCertificate(id: String!): Certificate! + deleteEducation(id: String!): Education! + deleteProject(id: String!): Project! deleteUser(id: String!): JSON! refreshAccessToken: JwtWithUser! requestEmailVerification(email: String!): Boolean! - signIn(input: SignInInput!): JwtWithUser! + signIn(input: SignInInputType!): JwtWithUser! signOut: Boolean! - signUp(input: SignUpInput!): JwtWithUser! - updateUser(id: String!, input: UpdateUserInput!): JSON! - uploadFile(file: Upload!): String! - uploadFiles(files: [Upload!]!): [String!]! + signUp(input: SignUpInputType!): JwtWithUser! + updateActivity(input: UpdateActivityInput!): Activity! + updateCareer(input: UpdateCareerInput!): Career! + updateCertificate(input: UpdateCertificateInput!): Certificate! + updateDailyLog(input: UpdateDailyLogInput!): DailyLog! + updateEducation(input: UpdateEducationInput!): Education! + updateProject(input: UpdateProjectInput!): Project! + updateUser(id: String!, input: UpdateUserInputType!): JSON! + updateUserProfile(input: UpdateUserProfileInput!): UserProfile! verifyEmailCode(code: String!, email: String!): Boolean! } @@ -120,22 +292,57 @@ enum ProjectType { } type Query { + activities: [Activity!]! + activity(id: String!): Activity! + getCertificate(id: String!): Certificate! + getCertificates: [Certificate!]! + getEducation(id: String!): Education! + getEducations: [Education!]! getManyUserList(input: GetManyInput): GetUserType! getMe: User! + getMyCareer(id: String!): Career! + getMyCareers: [Career!]! + getMyDailyLog(id: String!): DailyLog! + getMyDailyLogUsages: [DailyLogUsage!]! + getMyDailyLogs: [DailyLog!]! + getMyDailyLogsByMonth(month: Float!, year: Float!): [DailyLog!]! + getMyMonthlyUsageCount(month: Float!, type: DailyLogUsageType!, year: Float!): Float! + getMyProject(id: String!): Project! + getMyProjects: [Project!]! + getMyResumeDownloadCount: Int! + getMyResumeDownloads: [ResumeDownload!]! + getMyTodayUsageCount(type: DailyLogUsageType!): Float! + getMyUserProfile: UserProfile getOneUser(id: String!): User! + recommendLogsForJob(company: String!, resumeStyle: String!): [RecommendedLog!]! +} + +type RecommendedLog { + logId: ID! + summary: String! +} + +type ResumeDownload { + createdAt: DateTime! + id: ID! + isDeleted: Boolean! + isDownloaded: Boolean! + user: User! + userId: String! } -input SignInInput { +input SignInInputType { email: String! password: String! } -input SignUpInput { +input SignUpInputType { confirmPassword: String! email: String! - nickname: String! + nickname: String password: String! termsAgreed: Boolean! + username: String! } enum SocialProvider { @@ -145,7 +352,66 @@ enum SocialProvider { NAVER } -input UpdateUserInput { +input UpdateActivityInput { + description: String + endDate: DateTime + id: String! + organization: String + startDate: DateTime + title: String +} + +input UpdateCareerInput { + companyName: String + endDate: DateTime + id: ID! + industry: IndustryType! + position: WorkArea! + startDate: DateTime + summary: String + userId: ID! +} + +input UpdateCertificateInput { + expiryDate: DateTime + id: String! + issuedDate: DateTime + issuer: String + name: String +} + +input UpdateDailyLogInput { + area: WorkArea + careerId: ID + content: String + id: ID! + projectId: ID + tag: DailyTag +} + +input UpdateEducationInput { + degree: String + endDate: DateTime + id: String! + major: String + schoolName: String! + startDate: DateTime! +} + +input UpdateProjectInput { + careerId: ID + description: String + endDate: DateTime + id: ID! + name: String + responsibility: String + startDate: DateTime + techStack: [String!] + type: ProjectType + userId: ID +} + +input UpdateUserInputType { nickname: String password: String provider: SocialProvider @@ -155,14 +421,19 @@ input UpdateUserInput { username: String } -"""The `Upload` scalar type represents a file upload.""" -scalar Upload +input UpdateUserProfileInput { + blog: String + email: String + github: String + name: String + phone: String +} type User { createdAt: DateTime! email: String! id: ID! - nickname: String! + nickname: String provider: SocialProvider! providerId: String refreshToken: String @@ -171,6 +442,19 @@ type User { username: String! } +type UserProfile { + blog: String + createdAt: DateTime! + email: String + github: String + id: ID! + name: String + phone: String + updatedAt: DateTime! + user: User! + userId: String +} + enum WorkArea { AI_ML BACKEND diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index f8c2b29..b3e6ace 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,4 +1,3 @@ -// src/health/health.controller.ts import { Controller, Get } from '@nestjs/common'; import { HealthService } from './health.service'; diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 9875f9f..b486957 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,4 +1,3 @@ -// src/health/health.module.ts import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; diff --git a/src/health/health.service.ts b/src/health/health.service.ts index b152f2b..16a46ce 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -1,9 +1,8 @@ -// src/health/ping.service.ts import { Injectable } from '@nestjs/common'; -import dayjs from 'dayjs'; -import timezone from 'dayjs/plugin/timezone'; -import utc from 'dayjs/plugin/utc'; +const dayjs = require('dayjs'); +const utc = require('dayjs/plugin/utc'); +const timezone = require('dayjs/plugin/timezone'); dayjs.extend(utc); dayjs.extend(timezone); diff --git a/src/main.ts b/src/main.ts index a19fe24..748031d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; -import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js'; +// import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js'; import { AppModule } from './app.module'; import { EnvironmentVariables } from './common/helper/env.validation'; @@ -32,10 +32,10 @@ async function bootstrap() { }), ); - app.use( - '/graphql', - graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 }), - ); + // app.use( + // '/graphql', + // graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 }), + // ); app.enableCors({ origin: '*', diff --git a/src/resume/dto/create-user-profile.input.ts b/src/resume/dto/create-user-profile.input.ts new file mode 100644 index 0000000..2bf88ca --- /dev/null +++ b/src/resume/dto/create-user-profile.input.ts @@ -0,0 +1,42 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + IsUrl, +} from 'class-validator'; + +@InputType() +export class CreateUserProfileInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + name: string; + + @Field(() => String) + @IsNotEmpty() + @IsString() + phone: string; + + @Field(() => String) + @IsNotEmpty() + @IsEmail() + email: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsUrl() + github?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsUrl() + blog?: string; + + @Field(() => String) + @IsNotEmpty() + @IsString() + userId: string; +} diff --git a/src/resume/repositories/resume-user-daily-limit.respository.ts b/src/resume/repositories/resume-user-daily-limit.respository.ts index fabf300..56f81c1 100644 --- a/src/resume/repositories/resume-user-daily-limit.respository.ts +++ b/src/resume/repositories/resume-user-daily-limit.respository.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs'; +import * as dayjs from 'dayjs'; import { Between } from 'typeorm'; import { EntityManager } from 'typeorm/entity-manager/EntityManager'; diff --git a/src/resume/repositories/resume-user-download-limit-repository.ts b/src/resume/repositories/resume-user-download-limit-repository.ts index 7c3264f..386adfc 100644 --- a/src/resume/repositories/resume-user-download-limit-repository.ts +++ b/src/resume/repositories/resume-user-download-limit-repository.ts @@ -1,4 +1,4 @@ -import dayjs from 'dayjs'; +import * as dayjs from 'dayjs'; import { Between, EntityManager } from 'typeorm'; import { CustomRepository } from '../../common/decorators/typeorm.decorator'; diff --git a/src/resume/services/resume-daily-log-service.spec.ts b/src/resume/services/resume-daily-log-service.spec.ts new file mode 100644 index 0000000..6e8663a --- /dev/null +++ b/src/resume/services/resume-daily-log-service.spec.ts @@ -0,0 +1,155 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import * as dayjs from 'dayjs'; + +import { ChatGPTDailyTextService } from '../../common/external/chat/chatgpt-daily-text.service'; +import { ExceptionError } from '../../common/format/exceptions'; +import { DailyTag } from '../enums/daily-tag-enum'; +import { WorkArea } from '../enums/work-area.enum'; +import { DailyLogRepository } from '../repositories/resume-daily-log.repository'; +import { DailyLogService } from './resume-daily-log.service'; + +const mockDailyLogRepository = { + createDailyLog: jest.fn(), + findOneById: jest.fn(), + findAllByUserId: jest.fn(), + save: jest.fn(), + deleteDailyLog: jest.fn(), + findAllByUserIdAndDateRange: jest.fn(), +}; + +const mockChatGPTDailyTextService = { + generateSummary: jest.fn(), +}; + +describe('DailyLogService', () => { + let service: DailyLogService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DailyLogService, + { + provide: DailyLogRepository, + useValue: mockDailyLogRepository, + }, + { + provide: ChatGPTDailyTextService, + useValue: mockChatGPTDailyTextService, + }, + ], + }).compile(); + + service = module.get(DailyLogService); + }); + + const user = { id: 'user1' } as any; + + it('should create a daily log with summary', async () => { + const input = { + content: '오늘은 새로운 기능을 개발했습니다.', + tag: DailyTag.DEVELOPMENT, + area: WorkArea.BACKEND, + }; + + const summarized = '오늘 기능 개발 요약'; + + mockChatGPTDailyTextService.generateSummary.mockResolvedValue(summarized); + mockDailyLogRepository.createDailyLog.mockResolvedValue({ id: 'log1' }); + + const result = await service.create(user, input); + expect(mockChatGPTDailyTextService.generateSummary).toHaveBeenCalledWith( + input.content, + ); + expect(mockDailyLogRepository.createDailyLog).toHaveBeenCalledWith( + user.id, + { + ...input, + content: summarized, + }, + ); + expect(result.id).toBe('log1'); + }); + + it('should throw if content is empty', async () => { + await expect( + service.create(user, { + content: ' ', + tag: DailyTag.DEVELOPMENT, + area: WorkArea.BACKEND, + }), + ).rejects.toThrow(ExceptionError.invalidInput('').message); + }); + + it('should throw if summary generation fails', async () => { + const input = { + content: '내용2', + tag: DailyTag.DEVELOPMENT, + area: WorkArea.BACKEND, + }; + + mockChatGPTDailyTextService.generateSummary.mockResolvedValue(null); + + await expect(service.create(user, input)).rejects.toThrow( + ExceptionError.serverError('').message, + ); + }); + + it('should update a log if found', async () => { + const log = { id: 'log1', content: '이전 내용' }; + mockDailyLogRepository.findOneById.mockResolvedValue(log); + mockDailyLogRepository.save.mockResolvedValue({ + ...log, + content: '수정됨', + }); + + const result = await service.update({ id: 'log1', content: '수정됨' }); + expect(result.content).toBe('수정됨'); + }); + + it('should throw if log not found on update', async () => { + mockDailyLogRepository.findOneById.mockResolvedValue(null); + + await expect( + service.update({ id: 'notfound', content: 'x' }), + ).rejects.toThrow(ExceptionError.notFound('').message); + }); + + it('should return logs by user', async () => { + mockDailyLogRepository.findAllByUserId.mockResolvedValue([{ id: 'log1' }]); + + const result = await service.getMany(user); + expect(result).toHaveLength(1); + }); + + it('should return one log', async () => { + mockDailyLogRepository.findOneById.mockResolvedValue({ id: 'log1' }); + + const result = await service.getOne('log1'); + expect(result.id).toBe('log1'); + }); + + it('should throw if log not found in getOne', async () => { + mockDailyLogRepository.findOneById.mockResolvedValue(null); + + await expect(service.getOne('notfound')).rejects.toThrow( + ExceptionError.notFound('').message, + ); + }); + + it('should delete a log', async () => { + mockDailyLogRepository.deleteDailyLog.mockResolvedValue({ id: 'deleted' }); + + const result = await service.delete('deleted'); + expect(result.id).toBe('deleted'); + }); + + it('should get logs by month', async () => { + mockDailyLogRepository.findAllByUserIdAndDateRange.mockResolvedValue([ + { id: 'monthly' }, + ]); + + const result = await service.getByMonth(user, 2025, 6); + expect(result[0].id).toBe('monthly'); + }); +}); diff --git a/src/resume/services/resume-daily-log.service.ts b/src/resume/services/resume-daily-log.service.ts index a40666b..1328426 100644 --- a/src/resume/services/resume-daily-log.service.ts +++ b/src/resume/services/resume-daily-log.service.ts @@ -1,6 +1,6 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; -import dayjs from 'dayjs'; +import * as dayjs from 'dayjs'; import { ChatGPTDailyTextService } from '../../common/external/chat/chatgpt-daily-text.service'; import { ExceptionError } from '../../common/format/exceptions'; diff --git a/src/resume/services/resume-project-daily-log.service.spec.ts b/src/resume/services/resume-project-daily-log.service.spec.ts new file mode 100644 index 0000000..73449a4 --- /dev/null +++ b/src/resume/services/resume-project-daily-log.service.spec.ts @@ -0,0 +1,86 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ChatGPTDailyTextService } from '../../common/external/chat/chatgpt-daily-text.service'; +import { RecommendedLog } from '../output/resume-project-daily-log.output'; +import { ProjectDailyLogRepository } from '../repositories/resume-project-daily-log.repository'; +import { ProjectDailyLogService } from './resume-project-daily-log.service'; + +const mockProjectDailyLogRepository = { + findAllByUserId: jest.fn(), +}; + +const mockChatGPTDailyTextService = { + pickContentForResume: jest.fn(), +}; + +describe('ProjectDailyLogService', () => { + let service: ProjectDailyLogService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProjectDailyLogService, + { + provide: ProjectDailyLogRepository, + useValue: mockProjectDailyLogRepository, + }, + { + provide: ChatGPTDailyTextService, + useValue: mockChatGPTDailyTextService, + }, + ], + }).compile(); + + service = module.get(ProjectDailyLogService); + }); + + const mockUserId = 'user-123'; + const mockCompany = '카카오'; + const mockStyle = '간결한 스타일'; + + it('should return parsed logs from GPT response', async () => { + const mockLogs = [{ id: 'log1', content: '개발내용' }]; + const mockResponse = JSON.stringify([ + { logId: 'log1', summary: '요약된 내용 1' }, + { logId: 'log2', summary: '요약된 내용 2' }, + ]); + + mockProjectDailyLogRepository.findAllByUserId.mockResolvedValue(mockLogs); + mockChatGPTDailyTextService.pickContentForResume.mockResolvedValue( + mockResponse, + ); + + const result: RecommendedLog[] = await service.recommendLogs( + mockUserId, + mockCompany, + mockStyle, + ); + + expect(mockProjectDailyLogRepository.findAllByUserId).toHaveBeenCalledWith( + mockUserId, + ); + expect( + mockChatGPTDailyTextService.pickContentForResume, + ).toHaveBeenCalledWith({ + company: mockCompany, + resumeStyle: mockStyle, + logs: mockLogs, + }); + expect(result).toHaveLength(2); + expect(result[0].summary).toBe('요약된 내용 1'); + }); + + it('should throw serverError when GPT response is invalid JSON', async () => { + const mockLogs = [{ logId: 'log2', content: '기획 내용' }]; + const badJsonResponse = 'Not a JSON string'; + + mockProjectDailyLogRepository.findAllByUserId.mockResolvedValue(mockLogs); + mockChatGPTDailyTextService.pickContentForResume.mockResolvedValue( + badJsonResponse, + ); + + await expect( + service.recommendLogs(mockUserId, mockCompany, mockStyle), + ).rejects.toThrow(/GPT 응답 파싱 실패/); + }); +}); diff --git a/src/resume/services/resume-project-daily-log.service.ts b/src/resume/services/resume-project-daily-log.service.ts index c65259d..4b660c5 100644 --- a/src/resume/services/resume-project-daily-log.service.ts +++ b/src/resume/services/resume-project-daily-log.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ChatGPTDailyTextService } from '../../common/external/chat/chatgpt-daily-text.service'; -// GPT 요청 import { ExceptionError } from '../../common/format/exceptions'; import { RecommendedLog } from '../output/resume-project-daily-log.output'; import { ProjectDailyLogRepository } from '../repositories/resume-project-daily-log.repository'; diff --git a/src/resume/services/resume-user-activity.service.spec.ts b/src/resume/services/resume-user-activity.service.spec.ts new file mode 100644 index 0000000..423ce45 --- /dev/null +++ b/src/resume/services/resume-user-activity.service.spec.ts @@ -0,0 +1,115 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { TransactionService } from '../../common/transaction/transaction.service'; +import { Activity } from '../entities/resume-user-activity.entity'; +import { + CreateActivityInput, + UpdateActivityInput, +} from '../inputs/resume-user-activity.input'; +import { ActivityRepository } from '../repositories/resume-user-activity.repository'; +import { ActivityService } from './resume-user-activity.service'; + +const mockActivityRepository = { + createActivity: jest.fn(), + updateActivity: jest.fn(), + deleteActivity: jest.fn(), + findAllByUserId: jest.fn(), + findOneById: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb('mockManager')), +}; + +describe('ActivityService', () => { + let service: ActivityService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActivityService, + { provide: ActivityRepository, useValue: mockActivityRepository }, + { provide: TransactionService, useValue: mockTransactionService }, + ], + }).compile(); + + service = module.get(ActivityService); + }); + + const user = { id: 'user123' } as any; + + it('should create an activity in a transaction', async () => { + const input: CreateActivityInput = { + title: 'Test', + description: 'test desc', + } as any; + const activity: Activity = { id: 'a1', title: 'Test' } as any; + + mockActivityRepository.createActivity.mockResolvedValue(activity); + + const result = await service.create(user, input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockActivityRepository.createActivity).toHaveBeenCalledWith( + user.id, + input, + 'mockManager', + ); + expect(result).toBe(activity); + }); + + it('should update an activity in a transaction', async () => { + const input: UpdateActivityInput = { id: 'a1', title: 'Updated' } as any; + const activity: Activity = { id: 'a1', title: 'Updated' } as any; + + mockActivityRepository.updateActivity.mockResolvedValue(activity); + + const result = await service.update(input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockActivityRepository.updateActivity).toHaveBeenCalledWith( + input, + 'mockManager', + ); + expect(result).toBe(activity); + }); + + it('should delete an activity in a transaction', async () => { + const activity: Activity = { id: 'a1', title: 'To delete' } as any; + + mockActivityRepository.deleteActivity.mockResolvedValue(activity); + + const result = await service.delete('a1'); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockActivityRepository.deleteActivity).toHaveBeenCalledWith( + 'a1', + 'mockManager', + ); + expect(result).toBe(activity); + }); + + it('should find all activities by user ID', async () => { + const activities = [{ id: 'a1' }, { id: 'a2' }] as Activity[]; + + mockActivityRepository.findAllByUserId.mockResolvedValue(activities); + + const result = await service.findAllByUserId(user.id); + + expect(mockActivityRepository.findAllByUserId).toHaveBeenCalledWith( + user.id, + ); + expect(result).toHaveLength(2); + }); + + it('should find one activity by ID', async () => { + const activity = { id: 'a1', title: '조회용' } as Activity; + + mockActivityRepository.findOneById.mockResolvedValue(activity); + + const result = await service.findOneById('a1'); + + expect(mockActivityRepository.findOneById).toHaveBeenCalledWith('a1'); + expect(result).toBe(activity); + }); +}); diff --git a/src/resume/services/resume-user-activity.service.ts b/src/resume/services/resume-user-activity.service.ts index dc57744..6dfb4b3 100644 --- a/src/resume/services/resume-user-activity.service.ts +++ b/src/resume/services/resume-user-activity.service.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { User } from 'src/user/entities/user.entity'; - import { TransactionService } from '../../common/transaction/transaction.service'; +import { User } from '../../user/entities/user.entity'; import { Activity } from '../entities/resume-user-activity.entity'; import { CreateActivityInput, diff --git a/src/resume/services/resume-user-career.service.spec.ts b/src/resume/services/resume-user-career.service.spec.ts new file mode 100644 index 0000000..8f28f2f --- /dev/null +++ b/src/resume/services/resume-user-career.service.spec.ts @@ -0,0 +1,139 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { TransactionService } from '../../common/transaction/transaction.service'; +import { Career } from '../entities/resume-user-career.entity'; +import { WorkArea } from '../enums/work-area.enum'; +import { + CreateCareerInput, + UpdateCareerInput, +} from '../inputs/resume-user-career.input'; +import { CareerRepository } from '../repositories/resume-user-career.repository'; +import { CareerService } from './resume-user-career.service'; + +const mockCareerRepository = { + createCareer: jest.fn(), + updateCareer: jest.fn(), + deleteCareer: jest.fn(), + findOneById: jest.fn(), + findAllByUserId: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb('mockManager')), +}; + +describe('CareerService', () => { + let service: CareerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CareerService, + { provide: CareerRepository, useValue: mockCareerRepository }, + { provide: TransactionService, useValue: mockTransactionService }, + ], + }).compile(); + + service = module.get(CareerService); + }); + + const user = { id: 'user123' } as any; + + it('should create a career in transaction', async () => { + const input: CreateCareerInput = { title: '백엔드 개발자' } as any; + const expectedCareer = { + id: 'c1', + user: {} as any, + companyName: '테스트회사', + position: WorkArea.BACKEND, + startDate: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + } as Career; + + mockCareerRepository.createCareer.mockResolvedValue(expectedCareer); + + const result = await service.create(user, input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockCareerRepository.createCareer).toHaveBeenCalledWith( + user.id, + input, + 'mockManager', + ); + expect(result).toBe(expectedCareer); + }); + + it('should update a career in transaction', async () => { + const input: UpdateCareerInput = { id: 'c1', title: '수정된 제목' } as any; + const updatedCareer = { + id: 'c1', + user: {} as any, + companyName: '테스트회사', + position: WorkArea.BACKEND, + startDate: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + } as Career; + + mockCareerRepository.updateCareer.mockResolvedValue(updatedCareer); + + const result = await service.update(input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockCareerRepository.updateCareer).toHaveBeenCalledWith( + input, + 'mockManager', + ); + expect(result).toBe(updatedCareer); + }); + + it('should delete a career in transaction', async () => { + const foundCareer = { + id: 'c1', + } as Career; + + mockCareerRepository.findOneById.mockResolvedValue(foundCareer); + mockCareerRepository.deleteCareer.mockResolvedValue(undefined); + + const result = await service.delete('c1'); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockCareerRepository.findOneById).toHaveBeenCalledWith('c1'); + expect(mockCareerRepository.deleteCareer).toHaveBeenCalledWith( + 'c1', + 'mockManager', + ); + expect(result).toBe(foundCareer); + }); + + it('should get many careers by user', async () => { + const careers = [{ id: 'c1' }, { id: 'c2' }] as Career[]; + + mockCareerRepository.findAllByUserId.mockResolvedValue(careers); + + const result = await service.getMany(user); + + expect(mockCareerRepository.findAllByUserId).toHaveBeenCalledWith(user.id); + expect(result).toHaveLength(2); + }); + + it('should get one career by id', async () => { + const expectedCareer = { + id: 'c1', + user: {} as any, + companyName: '테스트회사', + position: WorkArea.BACKEND, + startDate: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + } as Career; + + mockCareerRepository.findOneById.mockResolvedValue(expectedCareer); + + const result = await service.getOne('c1'); + + expect(mockCareerRepository.findOneById).toHaveBeenCalledWith('c1'); + expect(result).toBe(expectedCareer); + }); +}); diff --git a/src/resume/services/resume-user-career.service.ts b/src/resume/services/resume-user-career.service.ts index 1def20d..562ad16 100644 --- a/src/resume/services/resume-user-career.service.ts +++ b/src/resume/services/resume-user-career.service.ts @@ -1,8 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; - -import { TransactionService } from 'src/common/transaction/transaction.service'; -import { User } from 'src/user/entities/user.entity'; +import { Injectable } from '@nestjs/common'; +import { TransactionService } from '../../common/transaction/transaction.service'; +import { User } from '../../user/entities/user.entity'; import { Career } from '../entities/resume-user-career.entity'; import { CreateCareerInput, diff --git a/src/resume/services/resume-user-certification.service.spec.ts b/src/resume/services/resume-user-certification.service.spec.ts new file mode 100644 index 0000000..e0ca280 --- /dev/null +++ b/src/resume/services/resume-user-certification.service.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { TransactionService } from '../../common/transaction/transaction.service'; +import { Certificate } from '../entities/resume-user-certificate.entity'; +import { + CreateCertificateInput, + UpdateCertificateInput, +} from '../inputs/resume-user-certificate.input'; +import { CertificateRepository } from '../repositories/resume-user-certification.repository'; +import { CertificateService } from './resume-user-certification.service'; + +const mockCertificateRepo = { + createCertificate: jest.fn(), + updateCertificate: jest.fn(), + deleteCertificate: jest.fn(), + findAllByUserId: jest.fn(), + findOneById: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb('mockManager')), +}; + +describe('CertificateService', () => { + let service: CertificateService; + const user = { id: 'user123' } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CertificateService, + { provide: CertificateRepository, useValue: mockCertificateRepo }, + { provide: TransactionService, useValue: mockTransactionService }, + ], + }).compile(); + + service = module.get(CertificateService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create certificate in transaction', async () => { + const input: CreateCertificateInput = { name: '정보처리기사' } as any; + const resultCertificate = { id: 'c1', name: '정보처리기사' } as Certificate; + + mockCertificateRepo.createCertificate.mockResolvedValue(resultCertificate); + + const result = await service.create(user, input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockCertificateRepo.createCertificate).toHaveBeenCalledWith( + user.id, + input, + 'mockManager', + ); + expect(result).toBe(resultCertificate); + }); + + it('should update certificate in transaction', async () => { + const input: UpdateCertificateInput = { + id: 'c1', + name: '수정된 자격증', + } as any; + const updatedCertificate = { + id: 'c1', + name: '수정된 자격증', + } as Certificate; + + mockCertificateRepo.updateCertificate.mockResolvedValue(updatedCertificate); + + const result = await service.update(input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockCertificateRepo.updateCertificate).toHaveBeenCalledWith( + input, + 'mockManager', + ); + expect(result).toBe(updatedCertificate); + }); + + it('should delete certificate in transaction', async () => { + const deletedCertificate = { + id: 'c1', + name: '삭제된 자격증', + } as Certificate; + + mockCertificateRepo.deleteCertificate.mockResolvedValue(deletedCertificate); + + const result = await service.delete('c1'); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockCertificateRepo.deleteCertificate).toHaveBeenCalledWith( + 'c1', + 'mockManager', + ); + expect(result).toBe(deletedCertificate); + }); + + it('should find all certificates by userId', async () => { + const certificates = [{ id: 'c1' }, { id: 'c2' }] as Certificate[]; + + mockCertificateRepo.findAllByUserId.mockResolvedValue(certificates); + + const result = await service.findAllByUserId(user.id); + + expect(mockCertificateRepo.findAllByUserId).toHaveBeenCalledWith(user.id); + expect(result).toBe(certificates); + }); + + it('should find one certificate by id', async () => { + const certificate = { id: 'c1', name: '조회된 자격증' } as Certificate; + + mockCertificateRepo.findOneById.mockResolvedValue(certificate); + + const result = await service.findOneById('c1'); + + expect(mockCertificateRepo.findOneById).toHaveBeenCalledWith('c1'); + expect(result).toBe(certificate); + }); +}); diff --git a/src/resume/services/resume-user-certification.service.ts b/src/resume/services/resume-user-certification.service.ts index e6c87cc..abc72ef 100644 --- a/src/resume/services/resume-user-certification.service.ts +++ b/src/resume/services/resume-user-certification.service.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { TransactionService } from 'src/common/transaction/transaction.service'; -import { User } from 'src/user/entities/user.entity'; - +import { TransactionService } from '../../common/transaction/transaction.service'; +import { User } from '../../user/entities/user.entity'; import { Certificate } from '../entities/resume-user-certificate.entity'; import { CreateCertificateInput, diff --git a/src/resume/services/resume-user-daily-limit.service.spec.ts b/src/resume/services/resume-user-daily-limit.service.spec.ts new file mode 100644 index 0000000..847c5c2 --- /dev/null +++ b/src/resume/services/resume-user-daily-limit.service.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import * as dayjs from 'dayjs'; + +import { + DAILY_LIMIT, + MONTHLY_LIMIT, +} from '../../common/constants/usage-limits.constant'; +import { TransactionService } from '../../common/transaction/transaction.service'; +import { DailyLogUsage } from '../entities/resume-user-daily-limit.entity'; +import { DailyLogUsageType } from '../enums/daily-log-usage-type-enum'; +import { CreateDailyLogUsageInput } from '../inputs/resume-user-daily-limit.input'; +import { DailyLogUsageRepository } from '../repositories/resume-user-daily-limit.respository'; +import { DailyLogUsageService } from './resume-user-daily-limit.service'; + +const mockRepo = { + countTodayUsage: jest.fn(), + countMonthlyUsage: jest.fn(), + createUsage: jest.fn(), + findAllByUserId: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb('mockManager')), +}; + +describe('DailyLogUsageService', () => { + let service: DailyLogUsageService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DailyLogUsageService, + { provide: DailyLogUsageRepository, useValue: mockRepo }, + { provide: TransactionService, useValue: mockTransactionService }, + ], + }).compile(); + + service = module.get(DailyLogUsageService); + }); + + const user = { id: 'user123' } as any; + const input: CreateDailyLogUsageInput = { type: DailyLogUsageType.SUMMARY }; + + it('should create usage if under limits', async () => { + mockRepo.countTodayUsage.mockResolvedValue(2); + mockRepo.countMonthlyUsage.mockResolvedValue(5); + const usage = { id: 'u1' } as DailyLogUsage; + mockRepo.createUsage.mockResolvedValue(usage); + + const result = await service.create(user, input); + + expect(mockRepo.countTodayUsage).toHaveBeenCalledWith(user.id, input.type); + expect(mockRepo.countMonthlyUsage).toHaveBeenCalledWith( + user.id, + input.type, + dayjs().year(), + dayjs().month() + 1, + ); + expect(mockRepo.createUsage).toHaveBeenCalledWith( + user.id, + input, + 'mockManager', + ); + expect(result).toBe(usage); + }); + + it('should throw if today usage exceeds DAILY_LIMIT', async () => { + mockRepo.countTodayUsage.mockResolvedValue(DAILY_LIMIT); + mockRepo.countMonthlyUsage.mockResolvedValue(0); + + await expect(service.create(user, input)).rejects.toThrow( + '오늘 사용 가능 횟수를 초과했습니다.', + ); + }); + + it('should throw if monthly usage exceeds MONTHLY_LIMIT', async () => { + mockRepo.countTodayUsage.mockResolvedValue(0); + mockRepo.countMonthlyUsage.mockResolvedValue(MONTHLY_LIMIT); + + await expect(service.create(user, input)).rejects.toThrow( + '이번 달 사용 가능 횟수를 초과했습니다.', + ); + }); + + it('should get today count', async () => { + mockRepo.countTodayUsage.mockResolvedValue(3); + + const result = await service.getTodayCount(user, DailyLogUsageType.SUMMARY); + expect(result).toBe(3); + }); + + it('should get monthly count', async () => { + mockRepo.countMonthlyUsage.mockResolvedValue(7); + + const result = await service.getMonthlyCount( + user, + DailyLogUsageType.SUMMARY, + 2025, + 6, + ); + expect(result).toBe(7); + }); + + it('should get all logs', async () => { + const logs = [{ id: 'log1' }] as DailyLogUsage[]; + mockRepo.findAllByUserId.mockResolvedValue(logs); + + const result = await service.getAllLogs(user); + expect(result).toBe(logs); + }); +}); diff --git a/src/resume/services/resume-user-daily-limit.service.ts b/src/resume/services/resume-user-daily-limit.service.ts index fd29d87..1aec9ae 100644 --- a/src/resume/services/resume-user-daily-limit.service.ts +++ b/src/resume/services/resume-user-daily-limit.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import dayjs from 'dayjs'; +import * as dayjs from 'dayjs'; import { DAILY_LIMIT, diff --git a/src/resume/services/resume-user-download-limit.service.spec.ts b/src/resume/services/resume-user-download-limit.service.spec.ts new file mode 100644 index 0000000..9e5bb81 --- /dev/null +++ b/src/resume/services/resume-user-download-limit.service.spec.ts @@ -0,0 +1,87 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import * as dayjs from 'dayjs'; + +import { MONTHLY_RESUME_DOWNLOAD_LIMIT } from '../../common/constants/usage-limits.constant'; +import { TransactionService } from '../../common/transaction/transaction.service'; +import { ResumeDownload } from '../entities/resume-user-download-limit.entity'; +import { ResumeDownloadRepository } from '../repositories/resume-user-download-limit-repository'; +import { ResumeDownloadService } from './resume-user-download-limit.service'; + +const mockRepo = { + getMonthlyDownloadCount: jest.fn(), + createDownload: jest.fn(), + findByUserId: jest.fn(), + getDownloadCount: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb('mockManager')), +}; + +describe('ResumeDownloadService', () => { + let service: ResumeDownloadService; + const user = { id: 'user123' } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ResumeDownloadService, + { provide: ResumeDownloadRepository, useValue: mockRepo }, + { provide: TransactionService, useValue: mockTransactionService }, + ], + }).compile(); + + service = module.get(ResumeDownloadService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should allow download if under limit', async () => { + mockRepo.getMonthlyDownloadCount.mockResolvedValue(3); + const download = { id: 'd1' } as ResumeDownload; + mockRepo.createDownload.mockResolvedValue(download); + + const result = await service.download(user); + + expect(mockRepo.getMonthlyDownloadCount).toHaveBeenCalledWith( + user.id, + dayjs().year(), + dayjs().month() + 1, + ); + expect(mockRepo.createDownload).toHaveBeenCalledWith( + user.id, + 'mockManager', + ); + expect(result).toBe(download); + }); + + it('should throw if monthly download limit is exceeded', async () => { + mockRepo.getMonthlyDownloadCount.mockResolvedValue( + MONTHLY_RESUME_DOWNLOAD_LIMIT, + ); + + await expect(service.download(user)).rejects.toThrow( + '이번 달 PDF 다운로드 횟수를 초과했습니다.', + ); + }); + + it('should return all download logs', async () => { + const logs = [{ id: 'd1' }, { id: 'd2' }] as ResumeDownload[]; + mockRepo.findByUserId.mockResolvedValue(logs); + + const result = await service.getAll(user); + expect(mockRepo.findByUserId).toHaveBeenCalledWith(user.id); + expect(result).toBe(logs); + }); + + it('should return total download count', async () => { + mockRepo.getDownloadCount.mockResolvedValue(7); + + const result = await service.getCount(user); + expect(mockRepo.getDownloadCount).toHaveBeenCalledWith(user.id); + expect(result).toBe(7); + }); +}); diff --git a/src/resume/services/resume-user-download-limit.service.ts b/src/resume/services/resume-user-download-limit.service.ts index 8f077d1..957c08a 100644 --- a/src/resume/services/resume-user-download-limit.service.ts +++ b/src/resume/services/resume-user-download-limit.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import dayjs from 'dayjs'; +import * as dayjs from 'dayjs'; import { MONTHLY_RESUME_DOWNLOAD_LIMIT } from '../../common/constants/usage-limits.constant'; import { ExceptionError } from '../../common/format/exceptions'; diff --git a/src/resume/services/resume-user-education.service.spec.ts b/src/resume/services/resume-user-education.service.spec.ts new file mode 100644 index 0000000..b7c5b72 --- /dev/null +++ b/src/resume/services/resume-user-education.service.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { TransactionService } from '../../common/transaction/transaction.service'; +import { Education } from '../entities/resume-user-education.entity'; +import { + CreateEducationInput, + UpdateEducationInput, +} from '../inputs/resume-user-education.input'; +import { EducationRepository } from '../repositories/resume-user-education.repository'; +import { EducationService } from './resume-user-education.service'; + +const mockEducationRepo = { + createEducation: jest.fn(), + updateEducation: jest.fn(), + deleteEducation: jest.fn(), + findAllByUserId: jest.fn(), + findOneById: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb('mockManager')), +}; + +describe('EducationService', () => { + let service: EducationService; + const user = { id: 'user123' } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EducationService, + { provide: EducationRepository, useValue: mockEducationRepo }, + { provide: TransactionService, useValue: mockTransactionService }, + ], + }).compile(); + + service = module.get(EducationService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create education in transaction', async () => { + const input: CreateEducationInput = { schoolName: '대학' } as any; + const resultEducation = { id: 'e1', schoolName: '대학' } as Education; + + mockEducationRepo.createEducation.mockResolvedValue(resultEducation); + + const result = await service.create(user, input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockEducationRepo.createEducation).toHaveBeenCalledWith( + user.id, + input, + 'mockManager', + ); + expect(result).toBe(resultEducation); + }); + + it('should update education in transaction', async () => { + const input: UpdateEducationInput = { + id: 'e1', + schoolName: '수정된 대학', + } as any; + const updatedEducation = { + id: 'e1', + schoolName: '수정된 대학', + } as Education; + + mockEducationRepo.updateEducation.mockResolvedValue(updatedEducation); + + const result = await service.update(input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockEducationRepo.updateEducation).toHaveBeenCalledWith( + input, + 'mockManager', + ); + expect(result).toBe(updatedEducation); + }); + + it('should delete education in transaction', async () => { + const deletedEducation = { + id: 'e1', + schoolName: '삭제된 학교', + } as Education; + + mockEducationRepo.deleteEducation.mockResolvedValue(deletedEducation); + + const result = await service.delete('e1'); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockEducationRepo.deleteEducation).toHaveBeenCalledWith( + 'e1', + 'mockManager', + ); + expect(result).toBe(deletedEducation); + }); + + it('should find all education entries by userId', async () => { + const educations = [{ id: 'e1' }, { id: 'e2' }] as Education[]; + + mockEducationRepo.findAllByUserId.mockResolvedValue(educations); + + const result = await service.findAllByUserId(user.id); + + expect(mockEducationRepo.findAllByUserId).toHaveBeenCalledWith(user.id); + expect(result).toBe(educations); + }); + + it('should find one education entry by id', async () => { + const education = { id: 'e1', schoolName: '조회된 학교' } as Education; + + mockEducationRepo.findOneById.mockResolvedValue(education); + + const result = await service.findOneById('e1'); + + expect(mockEducationRepo.findOneById).toHaveBeenCalledWith('e1'); + expect(result).toBe(education); + }); +}); diff --git a/src/resume/services/resume-user-education.service.ts b/src/resume/services/resume-user-education.service.ts index 7bf8d43..010d01b 100644 --- a/src/resume/services/resume-user-education.service.ts +++ b/src/resume/services/resume-user-education.service.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { User } from 'src/user/entities/user.entity'; - import { TransactionService } from '../../common/transaction/transaction.service'; +import { User } from '../../user/entities/user.entity'; import { Education } from '../entities/resume-user-education.entity'; import { CreateEducationInput, diff --git a/src/resume/services/resume-user-profile.service.spec.ts b/src/resume/services/resume-user-profile.service.spec.ts new file mode 100644 index 0000000..f06ce11 --- /dev/null +++ b/src/resume/services/resume-user-profile.service.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { TransactionService } from '../../common/transaction/transaction.service'; +import { User } from '../../user/entities/user.entity'; +import { UserProfile } from '../entities/resume-user-profile.entity'; +import { + CreateUserProfileInput, + UpdateUserProfileInput, +} from '../inputs/resume-user-profile.input'; +import { UserProfileRepository } from '../repositories/resume-user-profile.repository'; +import { UserProfileService } from './resume-user-profile.service'; + +const mockProfileRepo = { + existsByUserId: jest.fn(), + create: jest.fn(), + findByUserId: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb(mockManager)), +}; + +const mockManager = { + save: jest.fn(), +}; + +describe('UserProfileService', () => { + let service: UserProfileService; + const user = { id: 'user123' } as User; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserProfileService, + { provide: UserProfileRepository, useValue: mockProfileRepo }, + { provide: TransactionService, useValue: mockTransactionService }, + ], + }).compile(); + + service = module.get(UserProfileService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create profile if not exists', async () => { + const input: CreateUserProfileInput = { userId: 'user123' } as any; + const mockProfile = { + id: 'p1', + user: {} as any, + userId: 'user123', + name: 'testname', + email: 'test@test.com', + phone: '010-0000-0000', + github: 'https://github.com/test', + blog: 'https://blog.test.dev', + createdAt: new Date(), + updatedAt: new Date(), + } as UserProfile; + + mockProfileRepo.existsByUserId.mockResolvedValue(false); + mockProfileRepo.create.mockReturnValue(mockProfile); + mockManager.save.mockResolvedValue(mockProfile); + + const result = await service.create(user, input); + + expect(mockProfileRepo.existsByUserId).toHaveBeenCalledWith(user.id); + expect(mockProfileRepo.create).toHaveBeenCalledWith({ ...input, user }); + expect(mockManager.save).toHaveBeenCalledWith(mockProfile); + expect(result).toBe(mockProfile); + }); + + it('should throw if profile already exists', async () => { + mockProfileRepo.existsByUserId.mockResolvedValue(true); + + await expect(service.create(user, { bio: '중복' } as any)).rejects.toThrow( + '프로필은 이미 존재합니다.', + ); + }); + + it('should update existing profile', async () => { + const mockProfile = { + id: 'p1', + user: {} as any, + userId: 'user123', + name: 'testname', + email: 'test@test.com', + phone: '010-0000-0000', + github: 'https://github.com/test', + blog: 'https://blog.test.dev', + createdAt: new Date(), + updatedAt: new Date(), + } as UserProfile; + const input: UpdateUserProfileInput = { + github: 'https://github.com/test2', + } as any; + const updatedProfile = { ...mockProfile, ...input }; + + mockProfileRepo.findByUserId.mockResolvedValue(mockProfile); + mockManager.save.mockResolvedValue(updatedProfile); + + const result = await service.update(user, input); + + expect(mockProfileRepo.findByUserId).toHaveBeenCalledWith(user.id); + expect(mockManager.save).toHaveBeenCalledWith(updatedProfile); + expect(result.github).toBe('https://github.com/test2'); + }); + + it('should throw if profile not found on update', async () => { + mockProfileRepo.findByUserId.mockResolvedValue(null); + + await expect(service.update(user, { bio: '없음' } as any)).rejects.toThrow( + '프로필이 없습니다.', + ); + }); + + it('should get profile by userId', async () => { + const profile = { id: 'p1' } as UserProfile; + mockProfileRepo.findByUserId.mockResolvedValue(profile); + + const result = await service.get('user123'); + expect(mockProfileRepo.findByUserId).toHaveBeenCalledWith('user123'); + expect(result).toBe(profile); + }); +}); diff --git a/src/resume/services/resume-user-profile.service.ts b/src/resume/services/resume-user-profile.service.ts index 9b07030..38ae1f4 100644 --- a/src/resume/services/resume-user-profile.service.ts +++ b/src/resume/services/resume-user-profile.service.ts @@ -1,10 +1,8 @@ -// src/resume/user-profile.service.ts import { Injectable } from '@nestjs/common'; -import { ExceptionError } from 'src/common/format/exceptions'; -import { User } from 'src/user/entities/user.entity'; - +import { ExceptionError } from '../../common/format/exceptions'; import { TransactionService } from '../../common/transaction/transaction.service'; +import { User } from '../../user/entities/user.entity'; import { UserProfile } from '../entities/resume-user-profile.entity'; import { CreateUserProfileInput, diff --git a/src/resume/services/resume-user-project.service.spec.ts b/src/resume/services/resume-user-project.service.spec.ts new file mode 100644 index 0000000..dfe43e3 --- /dev/null +++ b/src/resume/services/resume-user-project.service.spec.ts @@ -0,0 +1,118 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { TransactionService } from '../../common/transaction/transaction.service'; +import { Project } from '../entities/resume-user-project.entity'; +import { + CreateProjectInput, + UpdateProjectInput, +} from '../inputs/resume-user-project.input'; +import { ProjectRepository } from '../repositories/resume-user-project.repository'; +import { ProjectService } from './resume-user-project.service'; + +const mockProjectRepository = { + createProject: jest.fn(), + updateProject: jest.fn(), + deleteProject: jest.fn(), + findOneById: jest.fn(), + findAllByUserId: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb('mockManager')), +}; + +describe('ProjectService', () => { + let service: ProjectService; + const user = { id: 'user123' } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProjectService, + { provide: ProjectRepository, useValue: mockProjectRepository }, + { provide: TransactionService, useValue: mockTransactionService }, + ], + }).compile(); + + service = module.get(ProjectService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create a project in transaction', async () => { + const input: CreateProjectInput = { name: 'My Project' } as any; + const createdProject = { id: 'p1', name: 'My Project' } as Project; + + mockProjectRepository.createProject.mockResolvedValue(createdProject); + + const result = await service.create(user, input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockProjectRepository.createProject).toHaveBeenCalledWith( + user.id, + input, + 'mockManager', + ); + expect(result).toBe(createdProject); + }); + + it('should update a project in transaction', async () => { + const input: UpdateProjectInput = { + id: 'p1', + name: 'Updated Project', + } as any; + const updatedProject = { id: 'p1', name: 'Updated Project' } as Project; + + mockProjectRepository.updateProject.mockResolvedValue(updatedProject); + + const result = await service.update(input); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockProjectRepository.updateProject).toHaveBeenCalledWith( + input, + 'mockManager', + ); + expect(result).toBe(updatedProject); + }); + + it('should delete a project in transaction', async () => { + const existingProject = { id: 'p1', name: 'To delete' } as Project; + + mockProjectRepository.findOneById.mockResolvedValue(existingProject); + mockProjectRepository.deleteProject.mockResolvedValue(undefined); + + const result = await service.delete('p1'); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockProjectRepository.findOneById).toHaveBeenCalledWith('p1'); + expect(mockProjectRepository.deleteProject).toHaveBeenCalledWith( + 'p1', + 'mockManager', + ); + expect(result).toBe(existingProject); + }); + + it('should get many projects by user ID', async () => { + const projects = [{ id: 'p1' }, { id: 'p2' }] as Project[]; + + mockProjectRepository.findAllByUserId.mockResolvedValue(projects); + + const result = await service.getMany(user); + + expect(mockProjectRepository.findAllByUserId).toHaveBeenCalledWith(user.id); + expect(result).toBe(projects); + }); + + it('should get one project by ID', async () => { + const project = { id: 'p1', name: '조회용' } as Project; + + mockProjectRepository.findOneById.mockResolvedValue(project); + + const result = await service.getOne('p1'); + + expect(mockProjectRepository.findOneById).toHaveBeenCalledWith('p1'); + expect(result).toBe(project); + }); +}); diff --git a/src/upload/upload.module.ts b/src/upload/upload.module.ts index d5869a9..66ef114 100644 --- a/src/upload/upload.module.ts +++ b/src/upload/upload.module.ts @@ -1,12 +1,12 @@ -import { HttpModule } from '@nestjs/axios'; -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +// import { HttpModule } from '@nestjs/axios'; +// import { Module } from '@nestjs/common'; +// import { ConfigModule } from '@nestjs/config'; -import { UploadResolver } from './upload.resolver'; -import { UploadService } from './upload.service'; +// import { UploadResolver } from './upload.resolver'; +// import { UploadService } from './upload.service'; -@Module({ - imports: [ConfigModule, HttpModule.register({})], - providers: [UploadService, UploadResolver], -}) -export class UploadModule {} +// @Module({ +// imports: [ConfigModule, HttpModule.register({})], +// providers: [UploadService, UploadResolver], +// }) +// export class UploadModule {} diff --git a/src/upload/upload.resolver.ts b/src/upload/upload.resolver.ts index 1d2ac9a..54a1149 100644 --- a/src/upload/upload.resolver.ts +++ b/src/upload/upload.resolver.ts @@ -1,53 +1,53 @@ -import { Args, Mutation, Resolver } from '@nestjs/graphql'; - -import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'; -import { FileUpload } from 'graphql-upload/Upload.js'; - -import { UploadService } from './upload.service'; - -@Resolver() -export class UploadResolver { - private readonly FOLDER_NAME = 'someFolderName'; - constructor(private readonly uploadService: UploadService) {} - - @Mutation(() => String) - async uploadFile( - @Args({ name: 'file', type: () => GraphQLUpload }) - file: FileUpload, - ): Promise { - const { key } = await this.uploadService.uploadFileToS3({ - folderName: this.FOLDER_NAME, - file, - }); - - return this.uploadService.getLinkByKey(key); - } - - @Mutation(() => [String]) - uploadFiles( - @Args({ name: 'files', type: () => [GraphQLUpload] }) - files: FileUpload[], - ): Promise { - return Promise.all( - files.map(async (file) => { - const { key } = await this.uploadService.uploadFileToS3({ - folderName: this.FOLDER_NAME, - file, - }); - - return this.uploadService.getLinkByKey(key); - }), - ); - } - - @Mutation(() => Boolean) - async deleteFiles( - @Args({ name: 'keys', type: () => [String] }) keys: string[], - ) { - const mapped = keys.map((key) => key.split('s3.amazonaws.com/')[1]); - for await (const key of mapped) { - this.uploadService.deleteS3Object(key); - } - return true; - } -} +// import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +// import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'; +// import { FileUpload } from 'graphql-upload/Upload.js'; + +// import { UploadService } from './upload.service'; + +// @Resolver() +// export class UploadResolver { +// private readonly FOLDER_NAME = 'someFolderName'; +// constructor(private readonly uploadService: UploadService) {} + +// @Mutation(() => String) +// async uploadFile( +// @Args({ name: 'file', type: () => GraphQLUpload }) +// file: FileUpload, +// ): Promise { +// const { key } = await this.uploadService.uploadFileToS3({ +// folderName: this.FOLDER_NAME, +// file, +// }); + +// return this.uploadService.getLinkByKey(key); +// } + +// @Mutation(() => [String]) +// uploadFiles( +// @Args({ name: 'files', type: () => [GraphQLUpload] }) +// files: FileUpload[], +// ): Promise { +// return Promise.all( +// files.map(async (file) => { +// const { key } = await this.uploadService.uploadFileToS3({ +// folderName: this.FOLDER_NAME, +// file, +// }); + +// return this.uploadService.getLinkByKey(key); +// }), +// ); +// } + +// @Mutation(() => Boolean) +// async deleteFiles( +// @Args({ name: 'keys', type: () => [String] }) keys: string[], +// ) { +// const mapped = keys.map((key) => key.split('s3.amazonaws.com/')[1]); +// for await (const key of mapped) { +// this.uploadService.deleteS3Object(key); +// } +// return true; +// } +// } diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts index b28b582..baf560b 100644 --- a/src/upload/upload.service.ts +++ b/src/upload/upload.service.ts @@ -1,116 +1,116 @@ -import { HttpService } from '@nestjs/axios'; -import { BadRequestException, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -import { - DeleteObjectCommand, - ListObjectsCommand, - S3, -} from '@aws-sdk/client-s3'; -import { Upload } from '@aws-sdk/lib-storage'; -import { FileUpload } from 'graphql-upload/Upload.js'; -import * as path from 'path'; -import { firstValueFrom } from 'rxjs'; - -import { EnvironmentVariables } from 'src/common/helper/env.validation'; - -@Injectable() -export class UploadService { - private readonly awsS3: S3; - public readonly S3_BUCKET_NAME: string; - - constructor( - private readonly configService: ConfigService, - private readonly httpService: HttpService, - ) { - this.awsS3 = new S3({ - region: this.configService.get('AWS_S3_REGION'), - credentials: { - accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), - secretAccessKey: this.configService.get('AWS_S3_SECRET_KEY'), - }, - }); - this.S3_BUCKET_NAME = this.configService.get('AWS_S3_BUCKET_NAME'); - } - - getLinkByKey(key: string) { - return `https://${this.configService.get( - 'AWS_S3_BUCKET_NAME', - )}.s3.amazonaws.com/${key}`; - } - - async uploadFileToS3({ - folderName, - file, - }: { - folderName: string; - file: FileUpload; - }) { - const key = `${folderName}/${new Date().toISOString()}_${path.basename( - file.filename, - )}`.replace(/ /g, ''); - - const upload = new Upload({ - client: this.awsS3, - params: { - Bucket: this.S3_BUCKET_NAME, - Key: key, - Body: file.createReadStream(), - ContentType: file.mimetype, - }, - }); - - try { - await upload.done(); - - return { key }; - } catch (error) { - throw new BadRequestException(`File upload failed : ${error}`); - } - } - - async deleteS3Object(key: string): Promise<{ success: true }> { - const command = new DeleteObjectCommand({ - Bucket: this.S3_BUCKET_NAME, - Key: key, - }); - - const check = new ListObjectsCommand({ - Bucket: this.S3_BUCKET_NAME, - Prefix: key, - }); - - const fileList = await this.awsS3.send(check); - - if (!fileList.Contents || fileList.Contents.length === 0) { - throw new BadRequestException(`File does not exist`); - } - - try { - await this.awsS3.send(command); - return { success: true }; - } catch (error) { - throw new BadRequestException(`Failed to delete file : ${error}`); - } - } - - async listS3Object(folder: string) { - const command = new ListObjectsCommand({ - Bucket: this.S3_BUCKET_NAME, - Prefix: folder, - }); - - const data = await this.awsS3.send(command); - - const promise = await Promise.all( - data.Contents.map(async (v) => { - const url = this.getLinkByKey(v.Key); - - const data = await firstValueFrom(this.httpService.get(url)); - return data; - }), - ); - - return promise.map((v) => v.data); - } -} +// import { HttpService } from '@nestjs/axios'; +// import { BadRequestException, Injectable } from '@nestjs/common'; +// import { ConfigService } from '@nestjs/config'; + +// import { +// DeleteObjectCommand, +// ListObjectsCommand, +// S3, +// } from '@aws-sdk/client-s3'; +// import { Upload } from '@aws-sdk/lib-storage'; +// import { FileUpload } from 'graphql-upload/Upload.js'; +// import * as path from 'path'; +// import { firstValueFrom } from 'rxjs'; + +// import { EnvironmentVariables } from 'src/common/helper/env.validation'; + +// @Injectable() +// export class UploadService { +// private readonly awsS3: S3; +// public readonly S3_BUCKET_NAME: string; + +// constructor( +// private readonly configService: ConfigService, +// private readonly httpService: HttpService, +// ) { +// this.awsS3 = new S3({ +// region: this.configService.get('AWS_S3_REGION'), +// credentials: { +// accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), +// secretAccessKey: this.configService.get('AWS_S3_SECRET_KEY'), +// }, +// }); +// this.S3_BUCKET_NAME = this.configService.get('AWS_S3_BUCKET_NAME'); +// } + +// getLinkByKey(key: string) { +// return `https://${this.configService.get( +// 'AWS_S3_BUCKET_NAME', +// )}.s3.amazonaws.com/${key}`; +// } + +// async uploadFileToS3({ +// folderName, +// file, +// }: { +// folderName: string; +// file: FileUpload; +// }) { +// const key = `${folderName}/${new Date().toISOString()}_${path.basename( +// file.filename, +// )}`.replace(/ /g, ''); + +// const upload = new Upload({ +// client: this.awsS3, +// params: { +// Bucket: this.S3_BUCKET_NAME, +// Key: key, +// Body: file.createReadStream(), +// ContentType: file.mimetype, +// }, +// }); + +// try { +// await upload.done(); + +// return { key }; +// } catch (error) { +// throw new BadRequestException(`File upload failed : ${error}`); +// } +// } + +// async deleteS3Object(key: string): Promise<{ success: true }> { +// const command = new DeleteObjectCommand({ +// Bucket: this.S3_BUCKET_NAME, +// Key: key, +// }); + +// const check = new ListObjectsCommand({ +// Bucket: this.S3_BUCKET_NAME, +// Prefix: key, +// }); + +// const fileList = await this.awsS3.send(check); + +// if (!fileList.Contents || fileList.Contents.length === 0) { +// throw new BadRequestException(`File does not exist`); +// } + +// try { +// await this.awsS3.send(command); +// return { success: true }; +// } catch (error) { +// throw new BadRequestException(`Failed to delete file : ${error}`); +// } +// } + +// async listS3Object(folder: string) { +// const command = new ListObjectsCommand({ +// Bucket: this.S3_BUCKET_NAME, +// Prefix: folder, +// }); + +// const data = await this.awsS3.send(command); + +// const promise = await Promise.all( +// data.Contents.map(async (v) => { +// const url = this.getLinkByKey(v.Key); + +// const data = await firstValueFrom(this.httpService.get(url)); +// return data; +// }), +// ); + +// return promise.map((v) => v.data); +// } +// } diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index c31b7d7..d4a051a 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -27,11 +27,11 @@ registerEnumType(SocialProvider, { }); @ObjectType() -@Entity() +@Entity('user') export class User { @Field(() => ID) @PrimaryColumn({ type: 'varchar', length: 26 }) - id: string = ulid(); // + id: string = ulid(); @Field(() => String) @IsEmail() @@ -116,7 +116,7 @@ export class User { // } } -@ObjectType() +@ObjectType('GetUserType') export class GetUserType { @Field(() => [User], { nullable: true }) data?: User[]; diff --git a/src/user/inputs/user.input.ts b/src/user/inputs/user.input.ts index 4564a95..3ec1492 100644 --- a/src/user/inputs/user.input.ts +++ b/src/user/inputs/user.input.ts @@ -5,7 +5,7 @@ import { IsEmail, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; import { User } from '../entities/user.entity'; import { SocialProvider } from '../enum/social-provider.enum'; -@InputType() +@InputType('CreateUserInputType') export class CreateUserInput implements Partial { @Field(() => String) @IsNotEmpty() @@ -42,7 +42,7 @@ export class CreateUserInput implements Partial { provider?: SocialProvider; } -@InputType() +@InputType('UpdateUserInputType') export class UpdateUserInput implements Partial { @Field(() => String, { nullable: true }) @IsOptional() @@ -75,7 +75,7 @@ export class UpdateUserInput implements Partial { provider?: SocialProvider; } -@InputType() +@InputType('UserIdInputType') export class UserIdInput { @Field(() => String) @IsNotEmpty() diff --git a/src/user/user.module.integration.spec.ts b/src/user/user.module.integration.spec.ts deleted file mode 100644 index aa3db9b..0000000 --- a/src/user/user.module.integration.spec.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; -import { HttpStatus, INestApplication } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { GraphQLModule } from '@nestjs/graphql'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import GraphQLJSON from 'graphql-type-json'; -import { join } from 'path'; -// import { IBackup } from 'pg-mem'; -import { DataType, newDb } from 'pg-mem'; -import * as request from 'supertest'; -import { DataSource } from 'typeorm'; -import { v4 } from 'uuid'; - -import { AuthModule } from 'src/auth/auth.module'; -import { formatError } from 'src/common/format/graphql-error.format'; -import { GetOneInput } from 'src/common/graphql/custom.input'; -import { GraphqlPassportAuthGuard } from 'src/common/guards/graphql-passport-auth.guard'; - -import { getEnvPath } from '../common/helper/env.helper'; -import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; -import { UserModule } from './user.module'; - -describe('UserModule', () => { - // let backup: IBackup; - let app: INestApplication; - let savedId: string; - - beforeAll(async () => { - const db = newDb({ - autoCreateForeignKeyIndices: true, - }); - - // To implement custom function - db.public.registerFunction({ - name: 'current_database', - implementation: () => 'test', - }); - - // To resolve swc compiler issue. If you don't use swc compiler, remove these lines - // https://github.com/oguimbal/pg-mem/issues/380 - db.public.registerFunction({ - name: 'obj_description', - args: [DataType.text, DataType.text], - returns: DataType.text, - implementation: () => 'test', - }); - - // To implement custom function - db.public.registerFunction({ - name: 'version', - implementation: () => 'user', - }); - - db.registerExtension('uuid-ossp', (schema) => { - schema.registerFunction({ - name: 'uuid_generate_v4', - returns: DataType.uuid, - implementation: v4, - impure: true, - }); - }); - - const datasource = await db.adapters.createTypeormDataSource({ - type: 'postgres', - entities: [join(process.cwd(), 'src', '**', '*.entity.{ts,js}')], - autoLoadEntities: true, - }); - - await datasource.initialize(); - - await datasource.synchronize(); - - const module: TestingModule = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot(), - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: getEnvPath(process.cwd()), - }), - GraphQLModule.forRootAsync({ - driver: ApolloDriver, - useFactory: () => ({ - context: ({ req }) => ({ req }), - cache: 'bounded', - formatError, - resolvers: { JSON: GraphQLJSON }, - autoSchemaFile: join(process.cwd(), 'test/graphql-schema.gql'), - sortSchema: true, - }), - }), - UserModule, - AuthModule, - ], - }) - .overrideProvider(DataSource) - .useValue(datasource) - .overrideGuard(GraphqlPassportAuthGuard) - .useValue({ canActivate: () => true }) - .compile(); - - app = module.createNestApplication(); - - await app.init(); - - // To make each test run independently, remove these comments below - // backup = db.backup(); - }); - - // afterEach(async () => { - // backup.restore(); - // }); - - afterAll(async () => { - await app.close(); - }); - - const created = { - username: 'someusername', - nickname: 'somenickname', - role: 'user', - }; - - it('create', async () => { - const keyName = 'createUser'; - - const gqlQuery = { - query: ` - mutation ($input: ${CreateUserInput.prototype.constructor.name}!) { - ${keyName}(input: $input) { - id - ${Object.keys(created).join('\n')} - } - } - `, - variables: { - input: { ...created, password: 'somepassword' }, - }, - }; - - await request(app.getHttpServer()) - .post('/graphql') - .send(gqlQuery) - .set('Content-Type', 'application/json') - .expect(HttpStatus.OK) - .expect(({ body: { data } }) => { - const { id } = data[keyName]; - savedId = id; - expect(data[keyName]).toMatchObject(created); - }); - }); - - it('getMany', async () => { - const keyName = 'getManyUserList'; - - const gqlQuery = { - query: ` - query { - ${keyName}{ - data { - ${Object.keys(created).join('\n')} - } - } - } - `, - }; - - await request(app.getHttpServer()) - .post('/graphql') - .send(gqlQuery) - .set('Content-Type', 'application/json') - .expect(HttpStatus.OK) - .expect(({ body: { data } }) => { - expect(data[keyName]).toMatchObject({ data: [created] }); - }); - }); - - it('getOne', async () => { - const keyName = 'getOneUser'; - - const gqlQuery = { - query: ` - query ($input: ${GetOneInput.prototype.constructor.name}!) { - ${keyName} (input:$input) { - ${Object.keys(created).join('\n')} - } - } - `, - variables: { - input: { - where: created, - }, - }, - }; - - await request(app.getHttpServer()) - .post('/graphql') - .send(gqlQuery) - .set('Content-Type', 'application/json') - .expect(HttpStatus.OK) - .expect(({ body: { data } }) => { - expect(data[keyName]).toMatchObject(created); - }); - }); - - it('update', async () => { - const keyName = 'updateUser'; - - const gqlQuery = { - query: ` - mutation ($id: String!, $input: ${UpdateUserInput.prototype.constructor.name}!) { - ${keyName}(id: $id, input: $input) - } - `, - variables: { - id: savedId, - input: { - username: 'changedusername', - }, - }, - }; - - await request(app.getHttpServer()) - .post('/graphql') - .send(gqlQuery) - .set('Content-Type', 'application/json') - .expect(HttpStatus.OK) - .expect(({ body: { data } }) => { - expect(data[keyName].affected).toBe(1); - }); - }); - - it('delete', async () => { - const keyName = 'deleteUser'; - - const gqlQuery = { - query: ` - mutation ($id: String!) { - ${keyName}(id: $id) - } - `, - variables: { - id: savedId, - }, - }; - - await request(app.getHttpServer()) - .post('/graphql') - .send(gqlQuery) - .set('Content-Type', 'application/json') - .expect(HttpStatus.OK) - .expect(({ body: { data } }) => { - expect(data[keyName].affected).toBe(1); - }); - }); -}); diff --git a/src/user/user.resolver.spec.ts b/src/user/user.resolver.spec.ts deleted file mode 100644 index a6bcd11..0000000 --- a/src/user/user.resolver.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; - -import { DataSource } from 'typeorm'; - -import { MockService, MockServiceFactory } from '../common/factory/mockFactory'; -import { GetManyInput, GetOneInput } from '../common/graphql/custom.input'; -import { UtilModule } from '../common/util/util.module'; -import { UtilService } from '../common/util/util.service'; -import { User } from './entities/user.entity'; -import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; -import { UserResolver } from './user.resolver'; -import { UserService } from './user.service'; - -describe('UserResolver', () => { - let resolver: UserResolver; - let mockedService: MockService; - let utilService: UtilService; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [UtilModule], - providers: [ - UserResolver, - { - provide: UserService, - useFactory: MockServiceFactory.getMockService(UserService), - }, - { - provide: DataSource, - useValue: undefined, - }, - ], - }).compile(); - - utilService = module.get(UtilService); - resolver = module.get(UserResolver); - mockedService = module.get>(UserService); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('Calling "Get many user list" method', () => { - const condition: GetManyInput = { - where: { id: utilService.getRandomUUID }, - }; - - const option = { relations: undefined, select: undefined }; - - expect(resolver.getManyUserList(condition, option)).not.toEqual(null); - expect(mockedService.getMany).toHaveBeenCalledWith({ - ...condition, - ...option, - }); - }); - - it('Calling "Get one user list" method', () => { - const id = utilService.getRandomUUID; - - const option = { relations: undefined, select: undefined }; - - expect(resolver.getOneUser(id)).not.toEqual(null); - expect(mockedService.getOne).toHaveBeenCalledWith(id); - }); - - it('Calling "Create user" method', () => { - const dto = new CreateUserInput(); - - expect(resolver.createUser(dto)).not.toEqual(null); - expect(mockedService.create).toHaveBeenCalledWith(dto); - }); - - it('Calling "Update user" method', () => { - const id = utilService.getRandomUUID; - const dto = new UpdateUserInput(); - - resolver.updateUser(id, dto); - - expect(mockedService.update).toHaveBeenCalledWith(id, dto); - }); - - it('Calling "Delete user" method', () => { - const id = utilService.getRandomUUID; - - resolver.deleteUser(id); - - expect(mockedService.delete).toHaveBeenCalledWith(id); - }); - - it('Calling "Get Me" method', () => { - const user = new User(); - - const condition: GetOneInput = { where: { id: user.id } }; - - const option = { relations: undefined, select: undefined }; - - expect(resolver.getMe(user)).not.toEqual(null); - expect(mockedService.getOne).toHaveBeenCalledWith(condition, option); - }); -}); diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 4783d58..315cc8a 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -1,89 +1,129 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { - MockRepository, - MockRepositoryFactory, -} from 'src/common/factory/mockFactory'; -import { ExtendedRepository } from 'src/common/graphql/customExtended'; -import { OneRepoQuery, RepoQuery } from 'src/common/graphql/types'; -import { UtilModule } from 'src/common/util/util.module'; -import { UtilService } from 'src/common/util/util.service'; +import { TransactionService } from '../common/transaction/transaction.service'; import { User } from './entities/user.entity'; import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; import { UserRepository } from './user.repository'; import { UserService } from './user.service'; +const mockUserRepository = { + getMany: jest.fn(), + getFindOneById: jest.fn(), + findByEmail: jest.fn(), + createUser: jest.fn(), + updateUser: jest.fn(), + getOne: jest.fn(), + deleteUser: jest.fn(), +}; + +const mockTransactionService = { + runInTransaction: jest.fn((cb) => cb('mockManager')), +}; + describe('UserService', () => { let service: UserService; - let mockedRepository: MockRepository>; - let utilService: UtilService; + const user = { + id: 'u1', + email: 'test@example.com', + username: 'tester', + } as User; - beforeAll(async () => { + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [UtilModule], providers: [ UserService, - { - provide: getRepositoryToken(UserRepository), - useFactory: MockRepositoryFactory.getMockRepository(UserRepository), - }, + { provide: UserRepository, useValue: mockUserRepository }, + { provide: TransactionService, useValue: mockTransactionService }, ], }).compile(); - utilService = module.get(UtilService); service = module.get(UserService); - mockedRepository = module.get>>( - getRepositoryToken(UserRepository), - ); }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); - it('Calling "Get many" method', () => { - const option: RepoQuery = { - where: { id: utilService.getRandomUUID }, - }; + it('should get many users', async () => { + const users = [user]; + mockUserRepository.getMany.mockResolvedValue(users); - expect(service.getMany(option)).not.toEqual(null); - expect(mockedRepository.getMany).toHaveBeenCalled(); + const result = await service.getMany(); + + expect(mockUserRepository.getMany).toHaveBeenCalled(); + expect(result).toBe(users); }); - it('Calling "Get one" method', () => { - const id = utilService.getRandomUUID; + it('should get one user by id', async () => { + mockUserRepository.getFindOneById.mockResolvedValue(user); + + const result = await service.getOne('u1'); - expect(service.getOne(id)).not.toEqual(null); - expect(mockedRepository.getOne).toHaveBeenCalled(); + expect(mockUserRepository.getFindOneById).toHaveBeenCalledWith('u1'); + expect(result).toBe(user); }); - it('Calling "Create" method', () => { - const dto = new CreateUserInput(); - const user = mockedRepository.create(dto); + it('should find user by email', async () => { + mockUserRepository.findByEmail.mockResolvedValue(user); + + const result = await service.findByEmail('test@example.com'); - expect(service.create(dto)).not.toEqual(null); - expect(mockedRepository.create).toHaveBeenCalledWith(dto); - expect(mockedRepository.save).toHaveBeenCalledWith(user); + expect(mockUserRepository.findByEmail).toHaveBeenCalledWith( + 'test@example.com', + ); + expect(result).toBe(user); }); - it('Calling "Update" method', () => { - const id = utilService.getRandomUUID; - const dto = new UpdateUserInput(); - const user = mockedRepository.create(dto); + it('should create user', async () => { + const input: CreateUserInput = { + email: 'test@example.com', + password: 'password', + username: 'tester', + role: 'user', + }; - service.update(id, dto); + mockUserRepository.createUser.mockResolvedValue(user); - expect(mockedRepository.create).toHaveBeenCalledWith(dto); - expect(mockedRepository.update).toHaveBeenCalledWith(id, user); + const result = await service.create(input); + + expect(mockUserRepository.createUser).toHaveBeenCalledWith( + input, + undefined, + ); + expect(result).toBe(user); }); - it('Calling "Delete" method', () => { - const id = utilService.getRandomUUID; + it('should update user and return updated entity', async () => { + const input: UpdateUserInput = { username: 'updated' }; + + mockUserRepository.updateUser.mockResolvedValue(undefined); + mockUserRepository.getOne.mockResolvedValue({ + ...user, + username: 'updated', + }); - service.delete(id); + const result = await service.update('u1', input); - expect(mockedRepository.delete).toHaveBeenCalledWith({ id }); + expect(mockUserRepository.updateUser).toHaveBeenCalledWith( + 'u1', + input, + undefined, + ); + expect(mockUserRepository.getOne).toHaveBeenCalledWith({ + where: { id: 'u1' }, + }); + expect(result.username).toBe('updated'); + }); + + it('should delete user in transaction', async () => { + mockUserRepository.deleteUser.mockResolvedValue(undefined); + + await service.delete('u1'); + + expect(mockTransactionService.runInTransaction).toHaveBeenCalled(); + expect(mockUserRepository.deleteUser).toHaveBeenCalledWith( + 'u1', + 'mockManager', + ); }); }); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index a56968d..ceb8b89 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { EntityManager } from 'typeorm'; import { CustomCache } from '../cache/custom-cache.decorator'; -import { OneRepoQuery, RepoQuery } from '../common/graphql/types'; +import { RepoQuery } from '../common/graphql/types'; import { TransactionService } from '../common/transaction/transaction.service'; import { User } from './entities/user.entity'; import { CreateUserInput, UpdateUserInput } from './inputs/user.input'; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index cbb5052..4684d3f 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,125 +1,329 @@ -import { HttpStatus, INestApplication } from '@nestjs/common'; +import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { INestApplication } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { GraphQLModule } from '@nestjs/graphql'; import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import * as bcrypt from 'bcrypt'; import * as request from 'supertest'; +import { DataSource } from 'typeorm'; -import { AppModule } from 'src/app.module'; -import { SignInInput, SignUpInput } from 'src/auth/inputs/auth.input'; +import { AuthModule } from 'src/auth/auth.module'; +import { GraphqlConfigService } from 'src/common/config/graphql-config.service'; +import { TypeORMConfigService } from 'src/common/config/ormconfig.service'; +import { getEnvPath } from 'src/common/helper/env.helper'; +import { envValidation } from 'src/common/helper/env.validation'; +import { IndustryType } from 'src/resume/enums/industry-type.enum'; +import { ProjectType } from 'src/resume/enums/project-type.enum'; +import { ResumeModule } from 'src/resume/resume.module'; +import { SocialProvider } from 'src/user/enum/social-provider.enum'; +import { UserModule } from 'src/user/user.module'; -const TEST = 'test'; -const userInfo = { - username: TEST, - nickname: TEST, -}; +import { DailyTag } from '../src/resume/enums/daily-tag-enum'; +import { WorkArea } from '../src/resume/enums/work-area.enum'; -const password = { - password: TEST, -}; - -describe('Container Test (e2e)', () => { +describe('Resume Flow (e2e)', () => { let app: INestApplication; - let savedJwt: string; + let dataSource: DataSource; + let accessToken: string; + let userId: string; + let careerId: string; + let projectId: string; + let moduleFixture: TestingModule; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], + moduleFixture = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: getEnvPath(`${__dirname}/../.test.env`), + validate: envValidation, + isGlobal: true, + }), + TypeOrmModule.forRootAsync({ + useClass: TypeORMConfigService, + imports: [ConfigModule], + }), + GraphQLModule.forRootAsync({ + driver: ApolloDriver, + useClass: GraphqlConfigService, + imports: [ConfigModule], + }), + UserModule, + AuthModule, + ResumeModule, + ], }).compile(); app = moduleFixture.createNestApplication(); + dataSource = app.get(DataSource); + + await dataSource.runMigrations(); await app.init(); + + // 테스트 유저 생성 + const hashedPassword = await bcrypt.hash('Test1234!', 10); + const result = await dataSource.query( + `INSERT INTO "user" (id, email, password, username, role, provider, "created_at", "updated_at") + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + RETURNING id`, + [ + '01HXSAXSAXSAXSAXSAXSAXSAX', + 'test@example.com', + hashedPassword, + 'testuser', + 'user', + SocialProvider.LOCAL, + ], + ); + userId = result[0].id; }); afterAll(async () => { + // 테스트 후 데이터베이스 정리 + await dataSource.dropDatabase(); await app.close(); }); - it('Health Check', () => { - return request(app.getHttpServer()).get('/health').expect(HttpStatus.OK); + describe('1. 로그인', () => { + it('should login and get JWT', () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + query: ` + mutation { + signIn(input: { + email: "test@example.com" + password: "Test1234!" + }) { + jwt + user { + id + email + } + } + } + `, + }) + .expect(200) + .expect((res) => { + expect(res.body.data.signIn.jwt).toBeDefined(); + expect(res.body.data.signIn.user.email).toBe('test@example.com'); + accessToken = res.body.data.signIn.jwt; + }); + }); }); - it('Sign Up', async () => { - const keyName = 'signUp'; - - const gqlQuery = { - query: ` - mutation ($input: ${SignUpInput.prototype.constructor.name}!) { - ${keyName}(input: $input) { - user{ - ${Object.keys(userInfo).join('\n')} + describe('2. 커리어 등록', () => { + it('should create a career', () => { + return request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + query: ` + mutation { + createCareer(input: { + companyName: "Test Company" + industry: "${IndustryType.IT}" + position: "${WorkArea.BACKEND}" + userId: "${userId}" + startDate: "2024-05-12T00:00:00.000Z" + endDate: "2024-05-12T00:00:00.000Z" + summary: "Test career summary" + }) { + id + companyName + position + industry + } } - } - } - `, - variables: { - input: { - ...userInfo, - ...password, - }, - }, - }; - - await request(app.getHttpServer()) - .post('/graphql') - .send(gqlQuery) - .set('Content-Type', 'application/json') - .expect(HttpStatus.OK) - .expect(({ body: { data } }) => { - expect(data[keyName]).toMatchObject({ user: userInfo }); - }); + `, + }) + .expect(200) + .expect((res) => { + expect(res.body.data.createCareer.id).toBeDefined(); + expect(res.body.data.createCareer.companyName).toBe('Test Company'); + careerId = res.body.data.createCareer.id; + }); + }); }); - it('Sign In', async () => { - const keyName = 'signIn'; + describe('3. 프로필 등록', () => { + it('should create a profile', () => { + return request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + query: ` + mutation { + createUserProfile(input: { + name: "Test User" + phone: "010-1234-5678" + email: "testuser@example.com" + github: "https://github.com/testuser" + blog: "https://blog.testuser.dev" + userId: "${userId}" + }) { + id + name + phone + email + github + blog + } + } + `, + }) + .expect(200) + .expect((res) => { + expect(res.body.data.createUserProfile.id).toBeDefined(); + expect(res.body.data.createUserProfile.name).toBe('Test User'); + expect(res.body.data.createUserProfile.phone).toBe('010-1234-5678'); + }); + }); + }); - const gqlQuery = { - query: ` - mutation ($input: ${SignInInput.prototype.constructor.name}!) { - ${keyName}(input: $input) { - jwt - } - } - `, - variables: { - input: { - username: userInfo.username, - ...password, - }, - }, - }; - - await request(app.getHttpServer()) - .post('/graphql') - .send(gqlQuery) - .set('Content-Type', 'application/json') - .expect(HttpStatus.OK) - .expect(({ body: { data } }) => { - const { jwt } = data[keyName]; - savedJwt = jwt; - expect(data[keyName]).toHaveProperty('jwt'); - }); + describe('4. 학력 등록', () => { + it('should create an education record', () => { + return request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + query: ` + mutation { + createEducation(input: { + userId: "${userId}" + schoolName: "Test University" + degree: "Bachelor" + major: "Computer Science" + startDate: "2024-05-12T00:00:00.000Z" + endDate: "2024-05-12T00:00:00.000Z" + }) { + id + schoolName + degree + } + } + `, + }) + .expect(200) + .expect((res) => { + expect(res.body.data.createEducation.id).toBeDefined(); + expect(res.body.data.createEducation.schoolName).toBe( + 'Test University', + ); + }); + }); }); - it('Get Me', async () => { - const keyName = 'getMe'; + describe('5. 프로젝트 기반 데일리 로그', () => { + it('should create a project first', () => { + return request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + query: ` + mutation { + createProject(input: { + name: "Test Project" + description: "Test project description" + startDate: "2024-05-12T00:00:00.000Z" + endDate: "2024-05-12T00:00:00.000Z" + techStack: ["Nest.js", "TypeScript", "PostgreSQL"] + responsibility: "Lead Developer" + userId: "${userId}" + type: "${ProjectType.WORK}" + }) { + id + name + } + } + `, + }) + .expect(200) + .expect((res) => { + expect(res.body.data.createProject.id).toBeDefined(); + projectId = res.body.data.createProject.id; + }); + }); - const gqlQuery = { - query: ` - query { - ${keyName} { - ${Object.keys(userInfo).join('\n')} + it('should create a project-based daily log with GPT response', () => { + return request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + query: ` + mutation { + createProjectDailyLog(input: { + projectId: "${projectId}" + content: "프로젝트에서 트랜잭션 관련 이슈를 해결했어. 성능 개선도 진행했고, 팀원들과 코드 리뷰를 진행했어." + tag: ${DailyTag.DEVELOPMENT} + area: ${WorkArea.BACKEND} + userId: "${userId}" + careerId: "${careerId}" + }) { + id + content + tag + area + project { + id + name + } + createdAt + } + } + `, + }) + .expect(200) + .expect((res) => { + expect(res.body.data.createProjectDailyLog.id).toBeDefined(); + expect(res.body.data.createProjectDailyLog.content).toBeDefined(); + expect(res.body.data.createProjectDailyLog.project.id).toBe( + projectId, + ); + }); + }); + + it('should recommend logs for job', () => { + return request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + query: ` + query { + recommendLogsForJob( + company: "Amazon Web Service" + resumeStyle: "BACKEND" + ) { + content + tag + area + project { + name + techStack + } + } + } + `, + }) + .expect(200) + .expect((res) => { + expect(Array.isArray(res.body.data.recommendLogsForJob)).toBe(true); + if (res.body.data.recommendLogsForJob.length > 0) { + expect(res.body.data.recommendLogsForJob[0]).toHaveProperty( + 'content', + ); + expect(res.body.data.recommendLogsForJob[0]).toHaveProperty('tag'); + expect(res.body.data.recommendLogsForJob[0]).toHaveProperty('area'); + expect(res.body.data.recommendLogsForJob[0].project).toHaveProperty( + 'name', + ); + expect(res.body.data.recommendLogsForJob[0].project).toHaveProperty( + 'techStack', + ); } - } - `, - }; - - await request(app.getHttpServer()) - .post('/graphql') - .send(gqlQuery) - .set('Content-Type', 'application/json') - .set('Authorization', 'Bearer ' + savedJwt) - .expect(HttpStatus.OK) - .expect(({ body: { data } }) => { - expect(data[keyName]).toMatchObject(userInfo); - }); + }); + }); }); }); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index f787699..2149d0c 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -1,17 +1,12 @@ { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], + "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.ts$": "ts-jest" }, - "moduleDirectories": [ - "/../", - "node_modules" - ] -} \ No newline at end of file + "moduleDirectories": ["/../", "node_modules"], + "transformIgnorePatterns": ["node_modules/(?!(dayjs)/)"], + "testPathIgnorePatterns": ["/node_modules/", "/dist/"] +}