From 901f17b79e25793c0bba23c0d8993251973a0613 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 26 Jul 2026 02:17:18 -0300 Subject: [PATCH 1/4] feat(simulado): add countByCategoria helper (filtra soft-deleted por deleted) --- src/modules/simulado/simulado.repository.spec.ts | 16 ++++++++++++++++ src/modules/simulado/simulado.repository.ts | 7 +++++++ 2 files changed, 23 insertions(+) create mode 100644 src/modules/simulado/simulado.repository.spec.ts diff --git a/src/modules/simulado/simulado.repository.spec.ts b/src/modules/simulado/simulado.repository.spec.ts new file mode 100644 index 0000000..cf6d7ea --- /dev/null +++ b/src/modules/simulado/simulado.repository.spec.ts @@ -0,0 +1,16 @@ +import { SimuladoRepository } from './simulado.repository'; + +describe('SimuladoRepository.countByCategoria', () => { + it('conta simulados não-deletados que referenciam a categoria', async () => { + const countDocuments = jest.fn().mockResolvedValue(3); + const repo = new SimuladoRepository({ countDocuments } as any); + + const total = await repo.countByCategoria('cat-123'); + + expect(total).toBe(3); + expect(countDocuments).toHaveBeenCalledWith({ + categoria: 'cat-123', + deleted: { $ne: true }, + }); + }); +}); diff --git a/src/modules/simulado/simulado.repository.ts b/src/modules/simulado/simulado.repository.ts index 03336b5..326c40a 100644 --- a/src/modules/simulado/simulado.repository.ts +++ b/src/modules/simulado/simulado.repository.ts @@ -12,6 +12,13 @@ export class SimuladoRepository extends BaseRepository { super(model); } + async countByCategoria(categoriaId: string): Promise { + return this.model.countDocuments({ + categoria: categoriaId, + deleted: { $ne: true }, + }); + } + async getById(id: string): Promise { return await this.model .findById(id) From 0250c6c27d6322ff2dcc7b313a6105056eb98485 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 26 Jul 2026 02:21:20 -0300 Subject: [PATCH 2/4] feat(categoria): bloquear delete de categoria em uso (409 com contador) --- .../categoria/categoria.service.spec.ts | 68 +++++++++++++++++++ src/modules/categoria/categoria.service.ts | 28 +++++++- 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 src/modules/categoria/categoria.service.spec.ts diff --git a/src/modules/categoria/categoria.service.spec.ts b/src/modules/categoria/categoria.service.spec.ts new file mode 100644 index 0000000..6f3a2e5 --- /dev/null +++ b/src/modules/categoria/categoria.service.spec.ts @@ -0,0 +1,68 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { CategoriaService } from './categoria.service'; + +function makeService(overrides?: { + getById?: jest.Mock; + deleteFn?: jest.Mock; + countByCategoria?: jest.Mock; +}) { + const repository = { + getById: overrides?.getById ?? jest.fn().mockResolvedValue({ _id: 'cat-1' }), + delete: overrides?.deleteFn ?? jest.fn().mockResolvedValue(undefined), + }; + const simuladoRepository = { + countByCategoria: overrides?.countByCategoria ?? jest.fn().mockResolvedValue(0), + }; + const service = new CategoriaService( + repository as any, + simuladoRepository as any, + ); + return { service, repository, simuladoRepository }; +} + +describe('CategoriaService.delete', () => { + it('deleta quando nenhum simulado usa a categoria', async () => { + const { service, repository, simuladoRepository } = makeService(); + + await service.delete('cat-1'); + + expect(simuladoRepository.countByCategoria).toHaveBeenCalledWith('cat-1'); + expect(repository.delete).toHaveBeenCalledWith('cat-1'); + }); + + it('lança 409 com o contador quando a categoria está em uso', async () => { + const { service, repository } = makeService({ + countByCategoria: jest.fn().mockResolvedValue(3), + }); + + await expect(service.delete('cat-1')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(repository.delete).not.toHaveBeenCalled(); + }); + + it('inclui simuladosUsando no payload do 409', async () => { + const { service } = makeService({ + countByCategoria: jest.fn().mockResolvedValue(2), + }); + + const error = await service.delete('cat-1').catch((e) => e); + + expect(error).toBeInstanceOf(ConflictException); + expect((error as ConflictException).getResponse()).toEqual({ + message: 'Categoria em uso e não pode ser excluída', + simuladosUsando: 2, + }); + }); + + it('lança 404 quando a categoria não existe', async () => { + const { service, simuladoRepository } = makeService({ + getById: jest.fn().mockResolvedValue(null), + }); + + await expect(service.delete('cat-x')).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(simuladoRepository.countByCategoria).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/categoria/categoria.service.ts b/src/modules/categoria/categoria.service.ts index ca2534b..8412459 100644 --- a/src/modules/categoria/categoria.service.ts +++ b/src/modules/categoria/categoria.service.ts @@ -1,13 +1,24 @@ -import { Injectable } from '@nestjs/common'; +import { + ConflictException, + Inject, + Injectable, + NotFoundException, + forwardRef, +} from '@nestjs/common'; import { GetAllInput } from 'src/shared/base/interfaces/get-all.input'; import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; +import { SimuladoRepository } from '../simulado/simulado.repository'; import { CreateCategoriaDTOInput } from './dtos/create.dto.input'; import { Categoria } from './schemas/categoria.schema'; import { CategoriaRepository } from './categoria.repository'; @Injectable() export class CategoriaService { - constructor(private readonly repository: CategoriaRepository) {} + constructor( + private readonly repository: CategoriaRepository, + @Inject(forwardRef(() => SimuladoRepository)) + private readonly simuladoRepository: SimuladoRepository, + ) {} public async add(item: CreateCategoriaDTOInput): Promise { const categoria = Object.assign(new Categoria(), item); @@ -23,6 +34,19 @@ export class CategoriaService { } public async delete(id: string): Promise { + const categoria = await this.repository.getById(id); + if (!categoria) { + throw new NotFoundException(`Categoria ${id} não encontrada`); + } + + const simuladosUsando = await this.simuladoRepository.countByCategoria(id); + if (simuladosUsando > 0) { + throw new ConflictException({ + message: 'Categoria em uso e não pode ser excluída', + simuladosUsando, + }); + } + await this.repository.delete(id); } } From 3df2bdb12284ddf4d554d428c77501dedae0a7a9 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 26 Jul 2026 02:26:12 -0300 Subject: [PATCH 3/4] fix(categoria): getAll filtra soft-deleted (deleted) preservando where e populate --- .../categoria/categoria.repository.spec.ts | 29 +++++++++++++++++++ src/modules/categoria/categoria.repository.ts | 6 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 src/modules/categoria/categoria.repository.spec.ts diff --git a/src/modules/categoria/categoria.repository.spec.ts b/src/modules/categoria/categoria.repository.spec.ts new file mode 100644 index 0000000..1161db3 --- /dev/null +++ b/src/modules/categoria/categoria.repository.spec.ts @@ -0,0 +1,29 @@ +import { CategoriaRepository } from './categoria.repository'; + +describe('CategoriaRepository.getAll', () => { + it('exclui soft-deleted (deleted: { $ne: true }) preservando where e populate', async () => { + const populate = jest.fn().mockResolvedValue([{ nome: 'Enem Dia 1' }]); + const chainWhere = jest.fn().mockReturnValue({ populate }); + const limit = jest.fn().mockReturnValue({ where: chainWhere }); + const skip = jest.fn().mockReturnValue({ limit }); + const find = jest.fn().mockReturnValue({ skip }); + + const countDocuments = jest.fn().mockResolvedValue(1); + const modelWhere = jest.fn().mockReturnValue({ countDocuments }); + + const model = { find, where: modelWhere } as any; + const repo = new CategoriaRepository(model); + + const result = await repo.getAll({ page: 1, limit: 10, where: { custom: true } } as any); + + expect(chainWhere).toHaveBeenCalledWith({ deleted: { $ne: true }, custom: true }); + expect(modelWhere).toHaveBeenCalledWith({ deleted: { $ne: true }, custom: true }); + expect(populate).toHaveBeenCalledWith('exame'); + expect(result).toEqual({ + data: [{ nome: 'Enem Dia 1' }], + page: 1, + limit: 10, + totalItems: 1, + }); + }); +}); diff --git a/src/modules/categoria/categoria.repository.ts b/src/modules/categoria/categoria.repository.ts index 6e62de6..27034ef 100644 --- a/src/modules/categoria/categoria.repository.ts +++ b/src/modules/categoria/categoria.repository.ts @@ -21,13 +21,15 @@ export class CategoriaRepository extends BaseRepository { limit, where, }: GetAllWhereInput): Promise> { + // guard por último: caller não pode sobrescrever o filtro de soft-delete + const filter = { ...where, deleted: { $ne: true } }; const data = await this.model .find() .skip((page - 1) * limit) .limit(limit ?? Infinity) - .where({ ...where }) + .where(filter) .populate('exame'); - const totalItems = await this.model.where({ ...where }).countDocuments(); + const totalItems = await this.model.where(filter).countDocuments(); return { data, page, limit, totalItems }; } } From 6d4d3acb4d0585a0e7436bfd9f752188a058bb07 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 26 Jul 2026 02:30:53 -0300 Subject: [PATCH 4/4] chore(modules): resolver ciclo CategoriaModule<->SimuladoModule com forwardRef Co-Authored-By: Claude Sonnet 4.6 --- src/modules/categoria/categoria.module.ts | 4 +++- src/modules/simulado/simulado.module.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/modules/categoria/categoria.module.ts b/src/modules/categoria/categoria.module.ts index 6ec66b2..82b3000 100644 --- a/src/modules/categoria/categoria.module.ts +++ b/src/modules/categoria/categoria.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { CategoriaService } from './categoria.service'; import { CategoriaController } from './categoria.controller'; import { CategoriaRepository } from './categoria.repository'; @@ -11,9 +11,11 @@ import { FrenteModule } from '../frente/frente.module'; import { MateriaModule } from '../materia/materia.module'; import { ExameModule } from '../exame/exame.module'; import { ExameExistValidator } from '../exame/validator/exame-exist.validator'; +import { SimuladoModule } from '../simulado/simulado.module'; @Module({ imports: [ + forwardRef(() => SimuladoModule), MongooseModule.forFeature([ { name: Categoria.name, schema: CategoriaSchema }, ]), diff --git a/src/modules/simulado/simulado.module.ts b/src/modules/simulado/simulado.module.ts index c5f91e8..c9433a7 100644 --- a/src/modules/simulado/simulado.module.ts +++ b/src/modules/simulado/simulado.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { QueueModule } from 'src/shared/modules/queue/queue.module'; import { ExameModule } from '../exame/exame.module'; @@ -23,7 +23,7 @@ import { SimuladoService } from './simulado.service'; ]), QueueModule, QuestaoModule, - CategoriaModule, + forwardRef(() => CategoriaModule), ExameModule, FrenteModule, MateriaModule,