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
4 changes: 3 additions & 1 deletion src/modules/categoria/categoria.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 },
]),
Expand Down
29 changes: 29 additions & 0 deletions src/modules/categoria/categoria.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
6 changes: 4 additions & 2 deletions src/modules/categoria/categoria.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ export class CategoriaRepository extends BaseRepository<Categoria> {
limit,
where,
}: GetAllWhereInput): Promise<GetAllOutput<Categoria>> {
// 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 };
}
}
68 changes: 68 additions & 0 deletions src/modules/categoria/categoria.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 26 additions & 2 deletions src/modules/categoria/categoria.service.ts
Original file line number Diff line number Diff line change
@@ -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<Categoria> {
const categoria = Object.assign(new Categoria(), item);
Expand All @@ -23,6 +34,19 @@ export class CategoriaService {
}

public async delete(id: string): Promise<void> {
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);
}
}
4 changes: 2 additions & 2 deletions src/modules/simulado/simulado.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,7 +23,7 @@ import { SimuladoService } from './simulado.service';
]),
QueueModule,
QuestaoModule,
CategoriaModule,
forwardRef(() => CategoriaModule),
ExameModule,
FrenteModule,
MateriaModule,
Expand Down
16 changes: 16 additions & 0 deletions src/modules/simulado/simulado.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -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 },
});
});
});
7 changes: 7 additions & 0 deletions src/modules/simulado/simulado.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export class SimuladoRepository extends BaseRepository<Simulado> {
super(model);
}

async countByCategoria(categoriaId: string): Promise<number> {
return this.model.countDocuments({
categoria: categoriaId,
deleted: { $ne: true },
});
}

async getById(id: string): Promise<Simulado | null> {
return await this.model
.findById(id)
Expand Down
Loading