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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion src/modules/categoria/categoria.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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?: {
Expand Down Expand Up @@ -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);
});
});
40 changes: 38 additions & 2 deletions src/modules/categoria/categoria.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
Expand All @@ -20,11 +21,46 @@ export class CategoriaService {
private readonly simuladoRepository: SimuladoRepository,
) {}

public async add(item: CreateCategoriaDTOInput): Promise<Categoria> {
const categoria = Object.assign(new Categoria(), item);
public async add(dto: CreateCategoriaDTOInput): Promise<Categoria> {
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 '<Prefixo> <Nq>|livre <Dmin>' (ex.: 'Personalizado 30q 60min')`,
);
}
}

public async getById(id: string): Promise<Categoria> {
return await this.repository.getById(id);
}
Expand Down
15 changes: 11 additions & 4 deletions src/modules/categoria/dtos/create.dto.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading