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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"backfill:criador-id": "ts-node scripts/backfill-criador-id.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest --detectOpenHandles --forceExit",
"test:watch": "jest --watch",
Expand Down
53 changes: 53 additions & 0 deletions scripts/backfill-criador-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Backfill one-off — Etapa 4 / Card 01.
*
* Preenche `criadorId = 'system'` em provas (e simulados) legados que não têm o
* campo, ANTES do deploy do schema novo (`Prova.criadorId` é `required`).
* Sem esse backfill, qualquer re-save de uma prova legada falharia na validação.
*
* Uso (rodar em janela de manutenção, apontando pro banco alvo):
* MONGODB="mongodb://.../db" npx ts-node scripts/backfill-criador-id.ts
* (ou com um .env contendo MONGODB, via dotenv/config já importado abaixo)
*
* Idempotente: só toca documentos sem `criadorId` (ou com `criadorId: null`).
*/
import 'dotenv/config';
import mongoose from 'mongoose';

const SENTINELA = 'system';
const FILTRO = { $or: [{ criadorId: { $exists: false } }, { criadorId: null }] };

async function run(): Promise<void> {
const uri = process.env.MONGODB;
if (!uri) {
console.error('❌ Variável de ambiente MONGODB não definida.');
process.exit(1);
}

await mongoose.connect(uri);
const db = mongoose.connection.db;
if (!db) {
console.error('❌ Falha ao obter a conexão do banco.');
process.exit(1);
}

const provas = await db
.collection('provas')
.updateMany(FILTRO, { $set: { criadorId: SENTINELA } });
console.log(`✓ Provas atualizadas: ${provas.modifiedCount}`);

// Simulado.criadorId é opcional; backfill por consistência (não é obrigatório).
const simulados = await db
.collection('simulados')
.updateMany(FILTRO, { $set: { criadorId: SENTINELA } });
console.log(`✓ Simulados atualizados: ${simulados.modifiedCount}`);

await mongoose.disconnect();
console.log('Backfill concluído.');
}

run().catch(async (err) => {
console.error(err);
await mongoose.disconnect().catch(() => undefined);
process.exit(1);
});
2 changes: 2 additions & 0 deletions src/modules/categoria/categoria.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CategoriaRepository } from './categoria.repository';
import { MongooseModule } from '@nestjs/mongoose';
import { Categoria, CategoriaSchema } from './schemas/categoria.schema';
import { CategoriaUniqueValidator } from './validator/categoria-unique.validator';
import { CategoriaExistValidator } from './validator/categoria-exist.validator';
import { MateriaExistValidator } from '../materia/validator/materia-exist.validator';
import { FrenteExistValidator } from '../frente/validator/frente-exist.validator';
import { FrenteModule } from '../frente/frente.module';
Expand All @@ -28,6 +29,7 @@ import { SimuladoModule } from '../simulado/simulado.module';
CategoriaService,
CategoriaRepository,
CategoriaUniqueValidator,
CategoriaExistValidator,
MateriaExistValidator,
FrenteExistValidator,
ExameExistValidator,
Expand Down
22 changes: 20 additions & 2 deletions src/modules/prova/dtos/create.dto.input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,29 @@ export class CreateProvaDTOInput {
@CategoriaExist({ message: 'categoria não existe' })
categoria: string;

@ApiProperty()
@ApiProperty({ required: false })
@IsOptional()
@IsString()
filename: string;
filename?: string;

@ApiProperty()
@IsOptional()
gabarito?: string;

@ApiProperty({ required: false })
@IsOptional()
@IsString()
nome?: string;

@ApiProperty({ required: false })
@IsOptional()
@IsString()
nomeSimulado?: string;

// Campo INTERNO: injetado pelo api-vcnafacul a partir do JWT (req.user.id),
// não vem do cliente. Precisa de @IsString() para sobreviver ao whitelist do
// ValidationPipe. Obrigatório em toda prova nova (oficial ou custom).
@ApiProperty()
@IsString()
criadorId: string;
}
286 changes: 286 additions & 0 deletions src/modules/prova/factory/custom_prova_factory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import { BadRequestException, HttpException } from '@nestjs/common';
import { CustomProvaFactory } from './custom_prova_factory';
import { Categoria } from 'src/modules/categoria/schemas/categoria.schema';

function makeSession() {
return {
startTransaction: jest.fn(),
commitTransaction: jest.fn().mockResolvedValue(undefined),
abortTransaction: jest.fn().mockResolvedValue(undefined),
endSession: jest.fn(),
};
}

function makeFactory(overrides?: {
categoria?: Partial<Categoria>;
getByFilter?: jest.Mock;
getById?: jest.Mock;
getProvaWithQuestion?: jest.Mock;
getByIdToUpdate?: jest.Mock;
session?: ReturnType<typeof makeSession>;
}) {
const session = overrides?.session ?? makeSession();
const questaoRepository = {
startSession: jest.fn().mockResolvedValue(session),
create: jest.fn().mockImplementation(async (q) => ({ ...q, _id: 'q-new' })),
getByIdToUpdate: overrides?.getByIdToUpdate ?? jest.fn(),
updateQuestion: jest.fn().mockResolvedValue(undefined),
};
const provaRepository = {
getByFilter: overrides?.getByFilter ?? jest.fn().mockResolvedValue(null),
getById: overrides?.getById ?? jest.fn(),
getProvaWithQuestion: overrides?.getProvaWithQuestion ?? jest.fn(),
addQuestion: jest.fn().mockResolvedValue(undefined),
removeQuestion: jest.fn().mockResolvedValue(undefined),
};
const simuladoService = {
addQuestionSimulados: jest.fn().mockResolvedValue(undefined),
removeQuestionSimulados: jest.fn().mockResolvedValue(undefined),
};
const simuladoRepository = {
create: jest.fn().mockImplementation(async (s) => ({ ...s, _id: 's-new' })),
};
const categoria = {
nome: 'Personalizado 30q 60min',
quantidadeTotalQuestao: 30,
exame: { _id: 'e1', nome: 'Personalizado' },
...overrides?.categoria,
} as unknown as Categoria;

const factory = new CustomProvaFactory(
questaoRepository as any,
provaRepository as any,
simuladoService as any,
simuladoRepository as any,
categoria,
);
return {
factory,
questaoRepository,
provaRepository,
simuladoService,
simuladoRepository,
categoria,
session,
};
}

describe('CustomProvaFactory.createProva', () => {
it('lança 400 quando o nome está vazio', async () => {
const { factory } = makeFactory();
await expect(
factory.createProva({ criadorId: 'u1', nome: ' ' } as any),
).rejects.toBeInstanceOf(BadRequestException);
});

it('lança 409 quando já existe prova com mesmo nome e criador', async () => {
const { factory, provaRepository } = makeFactory({
getByFilter: jest.fn().mockResolvedValue({ _id: 'existe' }),
});
await expect(
factory.createProva({ criadorId: 'u1', nome: 'Minha Prova' } as any),
).rejects.toBeInstanceOf(HttpException);
expect(provaRepository.getByFilter).toHaveBeenCalledWith({
nome: 'Minha Prova',
criadorId: 'u1',
});
});

it('cria prova com nome, criadorId, totalQuestao da categoria e enemAreas vazio', async () => {
const { factory } = makeFactory();
const prova = await factory.createProva({
criadorId: 'u1',
nome: 'Minha Prova',
ano: 2024,
} as any);
expect(prova.nome).toBe('Minha Prova');
expect(prova.criadorId).toBe('u1');
expect(prova.cursinhoId).toBeNull();
expect(prova.totalQuestao).toBe(30);
expect(prova.enemAreas).toEqual([]);
});

it('propaga totalQuestao null quando categoria é "livre"', async () => {
const { factory } = makeFactory({
categoria: { quantidadeTotalQuestao: null } as any,
});
const prova = await factory.createProva({
criadorId: 'u1',
nome: 'Livre',
} as any);
expect(prova.totalQuestao).toBeNull();
});
});

describe('CustomProvaFactory.createSimulados', () => {
it('lança 400 quando nomeSimulado não foi informado', async () => {
const { factory } = makeFactory();
await factory.createProva({
criadorId: 'u1',
nome: 'Minha Prova',
} as any);
const prova = { simulados: [], categoria: { exame: { nome: 'X' } } } as any;
await expect(factory.createSimulados(prova)).rejects.toBeInstanceOf(
BadRequestException,
);
});

it('cria exatamente 1 simulado com a categoria da prova e criador/cursinho propagados', async () => {
const { factory, simuladoRepository } = makeFactory();
await factory.createProva({
criadorId: 'u1',
nome: 'Minha Prova',
nomeSimulado: 'Simulado 1',
} as any);
const prova = {
simulados: [],
criadorId: 'u1',
cursinhoId: null,
categoria: { exame: { nome: 'Personalizado' } },
} as any;

await factory.createSimulados(prova);

expect(simuladoRepository.create).toHaveBeenCalledTimes(1);
const arg = simuladoRepository.create.mock.calls[0][0];
expect(arg.nome).toBe('Simulado 1');
expect(arg.categoria).toBe(prova.categoria);
expect(arg.criadorId).toBe('u1');
expect(arg.cursinhoId).toBeNull();
expect(prova.simulados).toHaveLength(1);
});
});

describe('CustomProvaFactory.createQuestion', () => {
it('cria a questão em transação e adiciona ao único simulado', async () => {
const provaToEnter = { _id: 'p1', simulados: [{ _id: 's1' }] };
const { factory, simuladoService, provaRepository, session } = makeFactory({
getById: jest.fn().mockResolvedValue(provaToEnter),
});

const result = await factory.createQuestion({
prova: 'p1',
numero: 1,
} as any);

expect(result._id).toBe('q-new');
expect(simuladoService.addQuestionSimulados).toHaveBeenCalledWith(
provaToEnter.simulados,
result,
session,
);
expect(provaRepository.addQuestion).toHaveBeenCalledWith('p1', result);
expect(session.commitTransaction).toHaveBeenCalledTimes(1);
expect(session.abortTransaction).not.toHaveBeenCalled();
expect(session.endSession).toHaveBeenCalledTimes(1);
});

it('faz rollback quando algo falha na transação', async () => {
const provaToEnter = { _id: 'p1', simulados: [{ _id: 's1' }] };
const { factory, provaRepository, session } = makeFactory({
getById: jest.fn().mockResolvedValue(provaToEnter),
});
provaRepository.addQuestion.mockRejectedValueOnce(new Error('boom'));

await expect(
factory.createQuestion({ prova: 'p1' } as any),
).rejects.toThrow('boom');
expect(session.abortTransaction).toHaveBeenCalledTimes(1);
expect(session.commitTransaction).not.toHaveBeenCalled();
expect(session.endSession).toHaveBeenCalledTimes(1);
});
});

describe('CustomProvaFactory.updateQuestion', () => {
it('mesma prova: apenas atualiza a questão (sem mexer em simulados)', async () => {
const questaoAtual = { _id: 'q1', prova: { _id: 'p1' } };
const { factory, questaoRepository, simuladoService, provaRepository } =
makeFactory({
getByIdToUpdate: jest.fn().mockResolvedValue(questaoAtual),
getById: jest.fn().mockResolvedValue({ _id: 'p1', simulados: [] }),
});

await factory.updateQuestion({ _id: 'q1', prova: 'p1' } as any);

expect(questaoRepository.updateQuestion).toHaveBeenCalledTimes(1);
expect(simuladoService.removeQuestionSimulados).not.toHaveBeenCalled();
expect(simuladoService.addQuestionSimulados).not.toHaveBeenCalled();
expect(provaRepository.removeQuestion).not.toHaveBeenCalled();
});

it('mudança de prova: remove da antiga e adiciona na nova', async () => {
const questaoAtual = { _id: 'q1', prova: { _id: 'p-old' } };
const oldProva = { _id: 'p-old', simulados: [{ _id: 's-old' }] };
const newProva = { _id: 'p-new', simulados: [{ _id: 's-new' }] };
const getById = jest
.fn()
.mockImplementation(async (id: string) =>
id === 'p-new' ? newProva : oldProva,
);
const { factory, simuladoService, provaRepository } = makeFactory({
getByIdToUpdate: jest.fn().mockResolvedValue(questaoAtual),
getById,
});

await factory.updateQuestion({ _id: 'q1', prova: 'p-new' } as any);

expect(simuladoService.removeQuestionSimulados).toHaveBeenCalledWith(
oldProva.simulados,
questaoAtual,
expect.anything(),
);
expect(provaRepository.removeQuestion).toHaveBeenCalledWith(
'p-old',
questaoAtual,
);
expect(simuladoService.addQuestionSimulados).toHaveBeenCalledWith(
newProva.simulados,
questaoAtual,
expect.anything(),
);
expect(provaRepository.addQuestion).toHaveBeenCalledWith(
'p-new',
questaoAtual,
);
});
});

describe('CustomProvaFactory.verifyNumberProva', () => {
it('retorna false quando o número já existe', async () => {
const { factory } = makeFactory({
getProvaWithQuestion: jest
.fn()
.mockResolvedValue({ questoes: [{ numero: 5 }] }),
});
expect(await factory.verifyNumberProva('p1', 5)).toBe(false);
});

it('retorna true quando o número ainda não existe', async () => {
const { factory } = makeFactory({
getProvaWithQuestion: jest
.fn()
.mockResolvedValue({ questoes: [{ numero: 5 }] }),
});
expect(await factory.verifyNumberProva('p1', 7)).toBe(true);
});
});

describe('CustomProvaFactory.getMissingNumbers', () => {
it('retorna [] quando quantidadeTotalQuestao é null', async () => {
const { factory } = makeFactory();
const prova = {
categoria: { quantidadeTotalQuestao: null },
questoes: [],
} as any;
expect(await factory.getMissingNumbers(prova)).toEqual([]);
});

it('retorna a faixa que falta quando a quantidade é numérica', async () => {
const { factory } = makeFactory();
const prova = {
categoria: { quantidadeTotalQuestao: 4 },
questoes: [{ numero: 2 }, { numero: 4 }],
} as any;
expect(await factory.getMissingNumbers(prova)).toEqual([1, 3]);
});
});
Loading
Loading