diff --git a/src/modules/categoria/categoria.service.spec.ts b/src/modules/categoria/categoria.service.spec.ts index 6f3a2e5..64f82ac 100644 --- a/src/modules/categoria/categoria.service.spec.ts +++ b/src/modules/categoria/categoria.service.spec.ts @@ -1,4 +1,4 @@ -import { ConflictException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { CategoriaService } from './categoria.service'; function makeService(overrides?: { @@ -66,3 +66,80 @@ describe('CategoriaService.delete', () => { expect(simuladoRepository.countByCategoria).not.toHaveBeenCalled(); }); }); + +describe('CategoriaService.add', () => { + function makeAddService(over?: { + getByFilter?: jest.Mock; + create?: jest.Mock; + }) { + const repository = { + getByFilter: over?.getByFilter ?? jest.fn().mockResolvedValue(null), + create: over?.create ?? jest.fn().mockImplementation(async (c) => c), + }; + const simuladoRepository = { countByCategoria: jest.fn() }; + const service = new CategoriaService( + repository as any, + simuladoRepository as any, + ); + return { service, repository }; + } + + it('auto-gera nome e força custom/selecionavel', async () => { + const { service, repository } = makeAddService(); + await service.add({ exame: 'e1', quantidadeTotalQuestao: 30, duracao: 60 } as any); + const saved = repository.create.mock.calls[0][0]; + expect(saved.nome).toBe('Personalizado 30q 60min'); + expect(saved.custom).toBe(true); + expect(saved.selecionavel).toBe(true); + // colisão é checada com o nome resolvido + expect(repository.getByFilter).toHaveBeenCalledWith({ + nome: 'Personalizado 30q 60min', + }); + }); + + it('auto-gera nome com prefixo fornecido', async () => { + const { service, repository } = makeAddService(); + await service.add({ exame: 'e1', prefixo: 'Mini Sabatina', quantidadeTotalQuestao: 20, duracao: 45 } as any); + expect(repository.create.mock.calls[0][0].nome).toBe('Mini Sabatina 20q 45min'); + }); + + it('normaliza espaços internos do prefixo (nome tem índice unique)', async () => { + const { service, repository } = makeAddService(); + await service.add({ exame: 'e1', prefixo: 'Mini Sabatina', quantidadeTotalQuestao: 20, duracao: 45 } as any); + expect(repository.create.mock.calls[0][0].nome).toBe('Mini Sabatina 20q 45min'); + }); + + it('gera "livre" quando quantidadeTotalQuestao é null', async () => { + const { service, repository } = makeAddService(); + await service.add({ exame: 'e1', quantidadeTotalQuestao: null, duracao: 60 } as any); + expect(repository.create.mock.calls[0][0].nome).toBe('Personalizado livre 60min'); + }); + + it('usa o nome explícito quando fornecido e válido', async () => { + const { service, repository } = makeAddService(); + await service.add({ nome: 'Custom 10q 30min', exame: 'e1', duracao: 30 } as any); + expect(repository.create.mock.calls[0][0].nome).toBe('Custom 10q 30min'); + }); + + it('lança 409 quando o nome já existe (colisão antes do pattern)', async () => { + const { service } = makeAddService({ getByFilter: jest.fn().mockResolvedValue({ _id: 'seed-enem' }) }); + await expect( + service.add({ nome: 'Enem Dia 1', exame: 'e1', duracao: 60 } as any), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('lança 400 quando o nome novo não segue o pattern', async () => { + const { service } = makeAddService(); + await expect( + service.add({ nome: 'Nome mal formatado', exame: 'e1', duracao: 60 } as any), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('força custom:true e selecionavel:true mesmo se o DTO enviar false', async () => { + const { service, repository } = makeAddService(); + await service.add({ nome: 'Custom 10q 30min', exame: 'e1', duracao: 30, custom: false, selecionavel: false } as any); + const saved = repository.create.mock.calls[0][0]; + expect(saved.custom).toBe(true); + expect(saved.selecionavel).toBe(true); + }); +}); diff --git a/src/modules/categoria/categoria.service.ts b/src/modules/categoria/categoria.service.ts index 8412459..e14a91c 100644 --- a/src/modules/categoria/categoria.service.ts +++ b/src/modules/categoria/categoria.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ConflictException, Inject, Injectable, @@ -20,11 +21,46 @@ export class CategoriaService { private readonly simuladoRepository: SimuladoRepository, ) {} - public async add(item: CreateCategoriaDTOInput): Promise { - const categoria = Object.assign(new Categoria(), item); + public async add(dto: CreateCategoriaDTOInput): Promise { + const nomeAplicado = dto.nome ?? this.gerarNomeAuto(dto); + + // colisão ANTES do pattern: nomes seedados (ex.: "Enem Dia 1") não seguem + // o pattern de categoria custom, então precisam bater 409 (não 400). + const collision = await this.repository.getByFilter({ nome: nomeAplicado }); + if (collision) { + throw new ConflictException('Já existe uma categoria com esse nome'); + } + + this.validarPatternNome(nomeAplicado); + + // backend é fonte de verdade: força os campos de segurança (ignora o DTO). + const categoria = Object.assign(new Categoria(), dto, { + nome: nomeAplicado, + custom: true, + selecionavel: true, + }); + return await this.repository.create(categoria); } + private gerarNomeAuto(dto: CreateCategoriaDTOInput): string { + // normaliza whitespace interno: nome tem índice unique, então "Mini X" e + // "Mini X" não podem virar categorias distintas. + const prefixo = dto.prefixo?.trim().replace(/\s+/g, ' ') || 'Personalizado'; + const qtd = dto.quantidadeTotalQuestao ?? 'livre'; + const parteQtd = qtd === 'livre' ? 'livre' : `${qtd}q`; + return `${prefixo} ${parteQtd} ${dto.duracao}min`; + } + + private validarPatternNome(nome: string): void { + const pattern = /^(?:\S+\s+)*?(?:\d+q|livre)\s+\d+min$/; + if (!pattern.test(nome)) { + throw new BadRequestException( + `Nome '${nome}' não segue o pattern ' |livre ' (ex.: 'Personalizado 30q 60min')`, + ); + } + } + public async getById(id: string): Promise { return await this.repository.getById(id); } diff --git a/src/modules/categoria/dtos/create.dto.input.ts b/src/modules/categoria/dtos/create.dto.input.ts index e70d987..5fe078e 100644 --- a/src/modules/categoria/dtos/create.dto.input.ts +++ b/src/modules/categoria/dtos/create.dto.input.ts @@ -6,14 +6,21 @@ import { IsOptional, IsString, } from 'class-validator'; -import { CategoriaUnique } from '../validator/categoria-unique.validator'; import { ExameExist } from '../../exame/validator/exame-exist.validator'; export class CreateCategoriaDTOInput { - @ApiProperty() + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + public nome?: string; + + @ApiProperty({ + required: false, + description: 'Prefixo para auto-gerar o nome quando "nome" não é enviado', + }) @IsString() - @CategoriaUnique({ message: 'nome categoria já existe' }) - public nome: string; + @IsOptional() + public prefixo?: string; @ApiProperty() @IsNumber()