diff --git a/backend/console-server/src/config/typeorm.config.ts b/backend/console-server/src/config/typeorm.config.ts index 79eaaf54..18248a23 100644 --- a/backend/console-server/src/config/typeorm.config.ts +++ b/backend/console-server/src/config/typeorm.config.ts @@ -2,27 +2,27 @@ import { registerAs } from '@nestjs/config'; import type { TypeOrmModuleOptions } from '@nestjs/typeorm'; export default registerAs('typeOrmConfig', () => { - const isDevEnv = ['development', 'test', 'debug'].includes( - process.env.NODE_ENV as string, - ); - return ( - isDevEnv - ? { - type: 'sqlite', - database: ':memory:', - dropSchema: true, - autoLoadEntities: true, - synchronize: true, - } - : { - type: 'mysql', - host: process.env.DB_HOST, - port: process.env.DB_PORT, - username: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME, - autoLoadEntities: true, - synchronize: true, - } - ) as TypeOrmModuleOptions; + const isDevEnv = ['development', 'test', 'debug'].includes(process.env.NODE_ENV as string); + return ( + isDevEnv + ? { + type: 'sqlite', + database: ':memory:', + dropSchema: true, + autoLoadEntities: true, + synchronize: true, + logging: ['query', 'error'], + logger: 'advanced-console', + } + : { + type: 'mysql', + host: process.env.DB_HOST, + port: process.env.DB_PORT, + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + autoLoadEntities: true, + synchronize: true, + } + ) as TypeOrmModuleOptions; }); diff --git a/backend/console-server/src/project/dto/find-by-generation-response.dto.ts b/backend/console-server/src/project/dto/find-by-generation-response.dto.ts new file mode 100644 index 00000000..cab98702 --- /dev/null +++ b/backend/console-server/src/project/dto/find-by-generation-response.dto.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty } from 'class-validator'; + +export class FindByGenerationResponseDto { + @IsNotEmpty() + name: string; +} diff --git a/backend/console-server/src/project/dto/find-by-generation.dto.ts b/backend/console-server/src/project/dto/find-by-generation.dto.ts new file mode 100644 index 00000000..f44d0e76 --- /dev/null +++ b/backend/console-server/src/project/dto/find-by-generation.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsNumber } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FindByGenerationDto { + @IsNotEmpty() + @Type(() => Number) + @IsNumber() + generation: number; +} diff --git a/backend/console-server/src/project/entities/project.entity.ts b/backend/console-server/src/project/entities/project.entity.ts index a64856ba..2e8048af 100644 --- a/backend/console-server/src/project/entities/project.entity.ts +++ b/backend/console-server/src/project/entities/project.entity.ts @@ -1,21 +1,24 @@ import { Entity, Unique } from 'typeorm'; import { Column, PrimaryGeneratedColumn } from 'typeorm'; -@Entity('projects') +@Entity('project') @Unique(['domain']) export class Project { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn() + id: number; - @Column({ type: 'varchar', length: 255 }) - name: string; + @Column({ type: 'varchar', length: 255 }) + name: string; - @Column({ type: 'varchar', length: 255 }) - ip: string; + @Column({ type: 'varchar', length: 255 }) + ip: string; - @Column({ type: 'varchar', length: 255, unique: true }) - domain: string; + @Column({ type: 'varchar', length: 255, unique: true }) + domain: string; - @Column({ type: 'varchar', length: 255 }) - email: string; + @Column({ type: 'varchar', length: 255 }) + email: string; + + @Column({ type: 'int' }) + generation: number; } diff --git a/backend/console-server/src/project/project.controller.ts b/backend/console-server/src/project/project.controller.ts index 31478d0e..22e486b4 100644 --- a/backend/console-server/src/project/project.controller.ts +++ b/backend/console-server/src/project/project.controller.ts @@ -1,16 +1,23 @@ -import { Controller, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; import { Post } from '@nestjs/common'; import { Body } from '@nestjs/common'; import { ProjectService } from './project.service'; import { CreateProjectDto } from './dto/create-project.dto'; +import { FindByGenerationDto } from './dto/find-by-generation.dto'; @Controller('project') export class ProjectController { - constructor(private readonly projectService: ProjectService) {} + constructor(private readonly projectService: ProjectService) {} - @Post() - @HttpCode(HttpStatus.CREATED) - create(@Body() createProjectDto: CreateProjectDto) { - return this.projectService.create(createProjectDto); - } + @Post() + @HttpCode(HttpStatus.CREATED) + create(@Body() createProjectDto: CreateProjectDto) { + return this.projectService.create(createProjectDto); + } + + @Get() + @HttpCode(HttpStatus.OK) + findByGeneration(@Query() findGenerationProjectDto: FindByGenerationDto) { + return this.projectService.findByGeneration(findGenerationProjectDto); + } } diff --git a/backend/console-server/src/project/project.service.spec.ts b/backend/console-server/src/project/project.service.spec.ts index ca209fb4..d044ca2c 100644 --- a/backend/console-server/src/project/project.service.spec.ts +++ b/backend/console-server/src/project/project.service.spec.ts @@ -9,81 +9,165 @@ import { QueryFailedError } from 'typeorm'; import type { CreateProjectDto } from './dto/create-project.dto'; import { ProjectResponseDto } from './dto/create-project-response.dto'; import { ConflictException } from '@nestjs/common'; +import { FindByGenerationDto } from './dto/find-by-generation.dto'; +import { FindByGenerationResponseDto } from './dto/find-by-generation-response.dto'; +import { plainToInstance } from 'class-transformer'; describe('ProjectService 클래스의', () => { - let projectService: ProjectService; - let projectRepository: Repository; - let mailService: MailService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ProjectService, - { - provide: getRepositoryToken(Project), - useValue: { - create: jest.fn(), - save: jest.fn(), - }, - }, - { - provide: MailService, - useValue: { - sendNameServerInfo: jest.fn(), - }, - }, - ], - }).compile(); - - projectService = module.get(ProjectService); - projectRepository = module.get>( - getRepositoryToken(Project), - ); - mailService = module.get(MailService); - }); - - describe('create() 메소드는', () => { - const createProjectDto: CreateProjectDto = { - name: '테스트 프로젝트', - email: 'test@test.com', - ip: '127.0.0.1', - domain: 'host.test.com', - }; - - it('올바른 정보가 들어왔을 때 프로젝트를 성공적으로 생성합니다.', async () => { - const projectEntity = { id: 1, ...createProjectDto }; - (projectRepository.create as jest.Mock).mockReturnValue(projectEntity); - (projectRepository.save as jest.Mock).mockReturnValue(projectEntity); - - const result = await projectService.create(createProjectDto); - - expect(projectRepository.create).toHaveBeenCalledWith(createProjectDto); - expect(projectRepository.save).toHaveBeenCalledWith(projectEntity); - expect(mailService.sendNameServerInfo).toHaveBeenCalledWith( - createProjectDto.email, - createProjectDto.name, - ); - expect(result).toBeInstanceOf(ProjectResponseDto); + let projectService: ProjectService; + let projectRepository: Repository; + let mailService: MailService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProjectService, + { + provide: getRepositoryToken(Project), + useValue: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + }, + }, + { + provide: MailService, + useValue: { + sendNameServerInfo: jest.fn(), + }, + }, + ], + }).compile(); + + projectService = module.get(ProjectService); + projectRepository = module.get>(getRepositoryToken(Project)); + mailService = module.get(MailService); }); - it('이미 존재하는 도메인이 들어오면 ConflictException을 던집니다.', async () => { - const error = new QueryFailedError('query', [], { - code: 'ER_DUP_ENTRY', - } as unknown as Error); - (projectRepository.create as jest.Mock).mockReturnValue({}); - (projectRepository.save as jest.Mock).mockRejectedValue(error); - - await expect(projectService.create(createProjectDto)).rejects.toThrow( - ConflictException, - ); + + describe('create() 메소드는', () => { + const createProjectDto: CreateProjectDto = { + name: '테스트 프로젝트', + email: 'test@test.com', + ip: '127.0.0.1', + domain: 'host.test.com', + }; + + it('올바른 정보가 들어왔을 때 프로젝트를 성공적으로 생성합니다.', async () => { + const projectEntity = { id: 1, ...createProjectDto }; + + (projectRepository.create as jest.Mock).mockReturnValue(projectEntity); + (projectRepository.save as jest.Mock).mockReturnValue(projectEntity); + + const result = await projectService.create(createProjectDto); + + expect(projectRepository.create).toHaveBeenCalledWith(createProjectDto); + expect(projectRepository.save).toHaveBeenCalledWith(projectEntity); + expect(mailService.sendNameServerInfo).toHaveBeenCalledWith( + createProjectDto.email, + createProjectDto.name, + ); + expect(result).toBeInstanceOf(ProjectResponseDto); + }); + it('이미 존재하는 도메인이 들어오면 ConflictException을 던집니다.', async () => { + const error = new QueryFailedError('query', [], { + code: 'ER_DUP_ENTRY', + } as unknown as Error); + + (projectRepository.create as jest.Mock).mockReturnValue({}); + (projectRepository.save as jest.Mock).mockRejectedValue(error); + + await expect(projectService.create(createProjectDto)).rejects.toThrow( + ConflictException, + ); + }); + it('예상치 못한 오류가 발생하면 해당 오류를 그대로 전달합니다.', async () => { + const error = new Error('예기치 못한 에러'); + (projectRepository.create as jest.Mock).mockReturnValue({}); + (projectRepository.save as jest.Mock).mockRejectedValue(error); + + await expect(projectService.create(createProjectDto)).rejects.toThrow(Error); + }); }); - it('예상치 못한 오류가 발생하면 해당 오류를 그대로 전달합니다.', async () => { - const error = new Error('예기치 못한 에러'); - (projectRepository.create as jest.Mock).mockReturnValue({}); - (projectRepository.save as jest.Mock).mockRejectedValue(error); - - await expect(projectService.create(createProjectDto)).rejects.toThrow( - Error, - ); + + describe('findByGeneration() 메소드는', () => { + it('특정 기수의 프로젝트 이름 목록을 반환합니다.', async () => { + // Given + const generation = 1; + const findGenerationProjectDto: FindByGenerationDto = { + generation, + }; + + const mockProjects = [ + { name: 'Project A' }, + { name: 'Project B' }, + { name: 'Project C' }, + ] as Project[]; + const expectedResponse = mockProjects.map((p) => + plainToInstance(FindByGenerationResponseDto, p.name), + ); + + (projectRepository.find as jest.Mock).mockResolvedValue(mockProjects); + + // When + const result = await projectService.findByGeneration(findGenerationProjectDto); + + // Then + expect(projectRepository.find).toHaveBeenCalledWith({ + select: { + name: true, + }, + where: { generation }, + }); + + expect(result).toHaveLength(mockProjects.length); + expect(result).toEqual(expectedResponse); + }); + + it('프로젝트가 없는 경우 빈 배열을 반환합니다.', async () => { + // Given + const generation = 999; + const findGenerationProjectDto: FindByGenerationDto = { + generation, + }; + + (projectRepository.find as jest.Mock).mockResolvedValue([]); + + // When + const result = await projectService.findByGeneration(findGenerationProjectDto); + + // Then + expect(projectRepository.find).toHaveBeenCalledWith({ + select: { + name: true, + }, + where: { generation }, + }); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('리포지토리 에러 발생 시 예외를 던집니다.', async () => { + // Given + const generation = 1; + const findGenerationProjectDto: FindByGenerationDto = { + generation, + }; + + const mockError = new Error('Database error'); + (projectRepository.find as jest.Mock).mockRejectedValue(mockError); + + // When & Then + await expect(projectService.findByGeneration(findGenerationProjectDto)).rejects.toThrow( + mockError, + ); + + expect(projectRepository.find).toHaveBeenCalledWith({ + select: { + name: true, + }, + where: { generation }, + }); + }); }); - }); }); diff --git a/backend/console-server/src/project/project.service.ts b/backend/console-server/src/project/project.service.ts index 1e435a79..8159315f 100644 --- a/backend/console-server/src/project/project.service.ts +++ b/backend/console-server/src/project/project.service.ts @@ -6,36 +6,53 @@ import { MailService } from '../mail/mail.service'; import type { CreateProjectDto } from './dto/create-project.dto'; import { ProjectResponseDto } from './dto/create-project-response.dto'; import { plainToInstance } from 'class-transformer'; +import { FindByGenerationDto } from './dto/find-by-generation.dto'; +import { FindByGenerationResponseDto } from './dto/find-by-generation-response.dto'; @Injectable() export class ProjectService { - constructor( - @InjectRepository(Project) - private readonly projectRepository: Repository, - private readonly mailService: MailService, - ) {} - - async create(createProjectDto: CreateProjectDto) { - try { - const project = this.projectRepository.create(createProjectDto); - const result = await this.projectRepository.save(project); - new Promise((resolve, _reject) => - this.mailService - .sendNameServerInfo(createProjectDto.email, createProjectDto.name) - .then(() => resolve), - ); - return plainToInstance(ProjectResponseDto, result); - } catch (error) { - if (isUniqueConstraintViolation(error)) - throw new ConflictException('Domain already exists.'); - throw error; + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + private readonly mailService: MailService, + ) {} + + + async create(createProjectDto: CreateProjectDto) { + try { + const project = this.projectRepository.create(createProjectDto); + const result = await this.projectRepository.save(project); + + new Promise((resolve, _reject) => + this.mailService + .sendNameServerInfo(createProjectDto.email, createProjectDto.name) + .then(() => resolve), + ); + return plainToInstance(ProjectResponseDto, result); + } catch (error) { + if (isUniqueConstraintViolation(error)) + throw new ConflictException('Domain already exists.'); + throw error; + } } - } + + async findByGeneration(findByGenerationDto: FindByGenerationDto) { + const generation = findByGenerationDto.generation; + + const projects = await this.projectRepository.find({ + select: { + name: true, + }, + where: { generation: generation }, + }); + + return projects.map((p) => plainToInstance(FindByGenerationResponseDto, p.name)); + } function isUniqueConstraintViolation(error: Error): boolean { - if (!(error instanceof QueryFailedError)) return false; - const code = error.driverError.code; - const uniqueViolationCodes = ['ER_DUP_ENTRY', 'SQLITE_CONSTRAINT']; - return uniqueViolationCodes.includes(code); + if (!(error instanceof QueryFailedError)) return false; + const code = error.driverError.code; + const uniqueViolationCodes = ['ER_DUP_ENTRY', 'SQLITE_CONSTRAINT']; + return uniqueViolationCodes.includes(code); }