diff --git a/package.json b/package.json index 8e7d735..0b4ce7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/backfill-criador-id.ts b/scripts/backfill-criador-id.ts new file mode 100644 index 0000000..af34d0b --- /dev/null +++ b/scripts/backfill-criador-id.ts @@ -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 { + 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); +}); diff --git a/src/modules/categoria/categoria.module.ts b/src/modules/categoria/categoria.module.ts index 82b3000..1772066 100644 --- a/src/modules/categoria/categoria.module.ts +++ b/src/modules/categoria/categoria.module.ts @@ -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'; @@ -28,6 +29,7 @@ import { SimuladoModule } from '../simulado/simulado.module'; CategoriaService, CategoriaRepository, CategoriaUniqueValidator, + CategoriaExistValidator, MateriaExistValidator, FrenteExistValidator, ExameExistValidator, diff --git a/src/modules/prova/dtos/create.dto.input.ts b/src/modules/prova/dtos/create.dto.input.ts index 8fc31f8..e05e88d 100644 --- a/src/modules/prova/dtos/create.dto.input.ts +++ b/src/modules/prova/dtos/create.dto.input.ts @@ -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; } diff --git a/src/modules/prova/factory/custom_prova_factory.spec.ts b/src/modules/prova/factory/custom_prova_factory.spec.ts new file mode 100644 index 0000000..0f98d2d --- /dev/null +++ b/src/modules/prova/factory/custom_prova_factory.spec.ts @@ -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; + getByFilter?: jest.Mock; + getById?: jest.Mock; + getProvaWithQuestion?: jest.Mock; + getByIdToUpdate?: jest.Mock; + session?: ReturnType; +}) { + 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]); + }); +}); diff --git a/src/modules/prova/factory/custom_prova_factory.ts b/src/modules/prova/factory/custom_prova_factory.ts new file mode 100644 index 0000000..91965bc --- /dev/null +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -0,0 +1,164 @@ +import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; +import { CreateQuestaoDTOInput } from 'src/modules/questao/dtos/create.dto.input'; +import { UpdateDTOInput } from 'src/modules/questao/dtos/update.dto.input'; +import { QuestaoRepository } from 'src/modules/questao/questao.repository'; +import { Questao } from 'src/modules/questao/questao.schema'; +import { SimuladoRepository } from 'src/modules/simulado/simulado.repository'; +import { SimuladoService } from 'src/modules/simulado/simulado.service'; +import { Categoria } from 'src/modules/categoria/schemas/categoria.schema'; +import { CreateProvaDTOInput } from '../dtos/create.dto.input'; +import { ProvaRepository } from '../prova.repository'; +import { Prova } from '../prova.schema'; +import { IProvaFactory } from './types'; + +/** + * Factory de provas personalizadas (categoria `custom: true`). + * + * Diferenças vs. factories ENEM: + * - Nome livre (vem do DTO), unicidade validada por criador. + * - Gera exatamente 1 simulado por prova (sem áreas / idiomáticas). + * - Sem `FrenteRepository`/`EnemService` — não há regras de Inglês/Espanhol. + * + * A `categoria` já vem resolvida (injetada pelo dispatcher no Card 03), por isso + * não injetamos `CategoriaRepository`. + */ +export class CustomProvaFactory implements IProvaFactory { + // Guardado em createProva para uso em createSimulados (mesma instância por + // request: o dispatcher cria uma factory nova a cada getFactory). + private provaItem: CreateProvaDTOInput | null = null; + + constructor( + private readonly questaoRepository: QuestaoRepository, + private readonly provaRepository: ProvaRepository, + private readonly simuladoService: SimuladoService, + private readonly simuladoRepository: SimuladoRepository, + private readonly categoria: Categoria, + ) {} + + async createProva(item: CreateProvaDTOInput): Promise { + if (!item.nome || !item.nome.trim()) { + throw new BadRequestException('Nome da prova é obrigatório'); + } + + const jaExiste = await this.provaRepository.getByFilter({ + nome: item.nome, + criadorId: item.criadorId, + }); + if (jaExiste) { + throw new HttpException( + 'Você já tem uma prova com esse nome', + HttpStatus.CONFLICT, + ); + } + + this.provaItem = item; + + // O constructor de Prova já seta criadorId (a partir do item), cursinhoId + // (null), filename e gabarito. + const prova = new Prova(item, this.categoria); + prova.nome = item.nome; + prova.totalQuestao = this.categoria.quantidadeTotalQuestao; + prova.enemAreas = []; + return prova; + } + + public async createSimulados(prova: Prova): Promise { + const nomeSimulado = this.provaItem?.nomeSimulado; + if (!nomeSimulado || !nomeSimulado.trim()) { + throw new BadRequestException('Nome do simulado é obrigatório'); + } + + const simulado = await this.simuladoRepository.create({ + nome: nomeSimulado, + descricao: `${prova.categoria.exame.nome}`, + categoria: prova.categoria, + questoes: [], + criadorId: prova.criadorId, + cursinhoId: prova.cursinhoId, + } as any); + + prova.simulados.push(simulado); + } + + public async createQuestion( + question: CreateQuestaoDTOInput, + ): Promise { + const questao = Object.assign(new Questao(), question); + const provaToEnter = await this.provaRepository.getById(question.prova); + + const session = await this.questaoRepository.startSession(); + session.startTransaction(); + try { + const result = await this.questaoRepository.create(questao); + await this.simuladoService.addQuestionSimulados( + provaToEnter.simulados, + result, + session, + ); + await this.provaRepository.addQuestion(question.prova, result); + await session.commitTransaction(); + return result; + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } + } + + public async updateQuestion(question: UpdateDTOInput): Promise { + const questao = await this.questaoRepository.getByIdToUpdate(question._id); + const provaToLeaveId = questao.prova?._id.toString(); + const provaToEnter = await this.provaRepository.getById(question.prova); + const changeProva = provaToLeaveId !== provaToEnter._id.toString(); + + const session = await this.questaoRepository.startSession(); + session.startTransaction(); + try { + if (changeProva && provaToLeaveId) { + const oldProva = await this.provaRepository.getById(provaToLeaveId); + await this.simuladoService.removeQuestionSimulados( + oldProva.simulados, + questao, + session, + ); + await this.provaRepository.removeQuestion(provaToLeaveId, questao); + await this.simuladoService.addQuestionSimulados( + provaToEnter.simulados, + questao, + session, + ); + await this.provaRepository.addQuestion(question.prova, questao); + } + await this.questaoRepository.updateQuestion(question); + await session.commitTransaction(); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } + } + + public async verifyNumberProva( + id: string, + numberQuestion: number, + ): Promise { + const prova = await this.provaRepository.getProvaWithQuestion(id); + return !prova.questoes.some((quest) => quest.numero === numberQuestion); + } + + public async getMissingNumbers(prova: Prova): Promise { + if (prova.categoria.quantidadeTotalQuestao == null) { + return []; + } + + const missingQuestion: number[] = []; + for (let i = 1; i <= prova.categoria.quantidadeTotalQuestao; i++) { + if (!prova.questoes.find((quest) => quest.numero === i)) { + missingQuestion.push(i); + } + } + return missingQuestion; + } +} diff --git a/src/modules/prova/factory/prova_factory.spec.ts b/src/modules/prova/factory/prova_factory.spec.ts new file mode 100644 index 0000000..c5c53e5 --- /dev/null +++ b/src/modules/prova/factory/prova_factory.spec.ts @@ -0,0 +1,49 @@ +import { ProvaFactory } from './prova_factory'; +import { CustomProvaFactory } from './custom_prova_factory'; +import { Enem2017PlusFactory } from './enem_2017_plus_factory'; +import { Enem2010_2017Factory } from './enem_2010_2016_factory'; +import { Categoria } from 'src/modules/categoria/schemas/categoria.schema'; + +function makeDispatcher() { + return new ProvaFactory( + {} as any, // categoriaRepository + {} as any, // questaoRepository + {} as any, // provaRepository + {} as any, // frenteRepository + {} as any, // simuladoService + {} as any, // simuladoRepository + {} as any, // enemService + ); +} + +describe('ProvaFactory.getFactory', () => { + it('roteia para CustomProvaFactory quando categoria.custom === true', () => { + const dispatcher = makeDispatcher(); + const categoria = { custom: true, exame: { nome: 'Personalizado' } } as unknown as Categoria; + expect(dispatcher.getFactory(categoria, 2024)).toBeInstanceOf( + CustomProvaFactory, + ); + }); + + it('roteia para Enem2017PlusFactory quando ENEM e ano > 2016', () => { + const dispatcher = makeDispatcher(); + const categoria = { custom: false, exame: { nome: 'ENEM' } } as unknown as Categoria; + expect(dispatcher.getFactory(categoria, 2020)).toBeInstanceOf( + Enem2017PlusFactory, + ); + }); + + it('roteia para Enem2010_2017Factory quando ENEM e 2010 <= ano <= 2016', () => { + const dispatcher = makeDispatcher(); + const categoria = { custom: false, exame: { nome: 'ENEM' } } as unknown as Categoria; + expect(dispatcher.getFactory(categoria, 2015)).toBeInstanceOf( + Enem2010_2017Factory, + ); + }); + + it('lança erro quando não há factory para o exame/ano', () => { + const dispatcher = makeDispatcher(); + const categoria = { custom: false, exame: { nome: 'OUTRO' } } as unknown as Categoria; + expect(() => dispatcher.getFactory(categoria, 2020)).toThrow(); + }); +}); diff --git a/src/modules/prova/factory/prova_factory.ts b/src/modules/prova/factory/prova_factory.ts index cb75c0f..c65fe42 100644 --- a/src/modules/prova/factory/prova_factory.ts +++ b/src/modules/prova/factory/prova_factory.ts @@ -1,12 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { Exame } from 'src/modules/exame/exame.schema'; import { FrenteRepository } from 'src/modules/frente/frente.repository'; import { QuestaoRepository } from 'src/modules/questao/questao.repository'; import { SimuladoRepository } from 'src/modules/simulado/simulado.repository'; import { SimuladoService } from 'src/modules/simulado/simulado.service'; import { CategoriaRepository } from 'src/modules/categoria/categoria.repository'; +import { Categoria } from 'src/modules/categoria/schemas/categoria.schema'; import { ProvaRepository } from '../prova.repository'; import { EnemService } from '../services/enem_service'; +import { CustomProvaFactory } from './custom_prova_factory'; import { Enem2010_2017Factory } from './enem_2010_2016_factory'; import { Enem2017PlusFactory } from './enem_2017_plus_factory'; import { ExameName, IProvaFactory } from './types'; @@ -23,7 +24,18 @@ export class ProvaFactory { private readonly enemService: EnemService, ) {} - public getFactory(exame: Exame, ano: number): IProvaFactory { + public getFactory(categoria: Categoria, ano: number): IProvaFactory { + if (categoria.custom) { + return new CustomProvaFactory( + this.questaoRepository, + this.provaRepository, + this.simuladoService, + this.simuladoRepository, + categoria, + ); + } + + const exame = categoria.exame; if (exame.nome === ExameName.ENEM && ano > 2016) { return new Enem2017PlusFactory( this.categoriaRepository, diff --git a/src/modules/prova/prova.schema.spec.ts b/src/modules/prova/prova.schema.spec.ts new file mode 100644 index 0000000..2b0f828 --- /dev/null +++ b/src/modules/prova/prova.schema.spec.ts @@ -0,0 +1,45 @@ +import mongoose from 'mongoose'; +import { Prova, ProvaSchema } from './prova.schema'; +import { CreateProvaDTOInput } from './dtos/create.dto.input'; +import { Categoria } from '../categoria/schemas/categoria.schema'; + +describe('Prova schema — criadorId / cursinhoId', () => { + const categoria = { nome: 'Enem Dia 1' } as unknown as Categoria; + + it('constructor seta criadorId a partir do item e cursinhoId = null', () => { + const item = { + criadorId: 'user-1', + ano: 2023, + filename: 'f.pdf', + } as unknown as CreateProvaDTOInput; + + const prova = new Prova(item, categoria); + + expect(prova.criadorId).toBe('user-1'); + expect(prova.cursinhoId).toBeNull(); + }); + + describe('validação do schema Mongoose', () => { + let Model: mongoose.Model; + + beforeAll(() => { + Model = mongoose.model('ProvaCard01Spec', ProvaSchema); + }); + + it('rejeita documento sem criadorId (required)', async () => { + const doc = new Model({ ano: 2023 }); + + const err = await doc.validate().catch((e) => e); + + expect(err).toBeInstanceOf(mongoose.Error.ValidationError); + expect(err.errors.criadorId).toBeDefined(); + }); + + it('aceita documento com criadorId e aplica default cursinhoId = null', async () => { + const doc = new Model({ criadorId: 'system' }); + + await expect(doc.validate()).resolves.toBeUndefined(); + expect(doc.cursinhoId).toBeNull(); + }); + }); +}); diff --git a/src/modules/prova/prova.schema.ts b/src/modules/prova/prova.schema.ts index 12442fe..7c42a53 100644 --- a/src/modules/prova/prova.schema.ts +++ b/src/modules/prova/prova.schema.ts @@ -17,6 +17,8 @@ export class Prova extends BaseSchema { this.filename = item.filename; this.gabarito = item.gabarito; this.aplicacao = item.aplicacao; + this.criadorId = item.criadorId; + this.cursinhoId = null; this.simulados = []; this.questoes = []; } @@ -65,6 +67,12 @@ export class Prova extends BaseSchema { @Prop({ default: 1 }) public inicialNumero: number = 1; + + @Prop({ required: true }) + public criadorId: string; + + @Prop({ required: false, default: null }) + public cursinhoId?: string | null; } export const ProvaSchema = SchemaFactory.createForClass(Prova); diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts new file mode 100644 index 0000000..0c3b7ec --- /dev/null +++ b/src/modules/prova/prova.service.spec.ts @@ -0,0 +1,107 @@ +import { ProvaService } from './prova.service'; +import { Status } from '../questao/enums/status.enum'; + +function makeService(overrides?: { getById?: jest.Mock }) { + const repository = { + getById: overrides?.getById ?? jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + }; + const simuladoRepository = { update: jest.fn().mockResolvedValue(undefined) }; + const service = new ProvaService( + {} as any, // provaFactory + repository as any, + {} as any, // categoriaRepository + simuladoRepository as any, + {} as any, // questaoRepository + {} as any, // frenteRepository + ); + return { service, repository, simuladoRepository }; +} + +describe('ProvaService.approvedQuestion — regra bloqueado com qtd null', () => { + it('desbloqueia simulado de categoria livre (null) quando todas aprovadas', async () => { + const simulado: any = { + _id: 's1', + questoes: [{ _id: 'q1', status: Status.Pending, numero: 1 }], + categoria: { quantidadeTotalQuestao: null }, + bloqueado: true, + }; + const prova = { + questoes: [{ _id: 'q1', status: Status.Pending }], + simulados: [simulado], + }; + const { service } = makeService({ + getById: jest.fn().mockResolvedValue(prova), + }); + + await service.approvedQuestion('p1', 'q1'); + + expect(simulado.bloqueado).toBe(false); + }); + + it('mantém bloqueado quando categoria numérica ainda não atingiu a quantidade', async () => { + const simulado: any = { + _id: 's1', + questoes: [{ _id: 'q1', status: Status.Pending, numero: 1 }], + categoria: { quantidadeTotalQuestao: 30 }, + bloqueado: true, + }; + const prova = { + questoes: [{ _id: 'q1', status: Status.Pending }], + simulados: [simulado], + }; + const { service } = makeService({ + getById: jest.fn().mockResolvedValue(prova), + }); + + await service.approvedQuestion('p1', 'q1'); + + expect(simulado.bloqueado).toBe(true); + }); +}); + +describe('ProvaService.selectQuestionsForSimulado — branch custom', () => { + it('prova custom: retorna TODAS as questões da prova (sem string matching)', () => { + const { service } = makeService(); + const questoes = [ + { _id: 'q1', numero: 1 }, + { _id: 'q2', numero: 2 }, + ]; + const simulado: any = { nome: 'Nome livre qualquer' } as any; + const prova = { categoria: { custom: true }, ano: 2024 } as any; + + const result = (service as any).selectQuestionsForSimulado( + simulado, + questoes, + prova, + undefined, + undefined, + ); + + expect(result).toHaveLength(2); + expect(result).toEqual(questoes); + }); + + it('prova oficial: simulado padrão recebe todas as questões (string matching preservado)', () => { + const { service } = makeService(); + const questoes = [ + { _id: 'q1', numero: 1, enemArea: 'Matemática' }, + { _id: 'q2', numero: 2, enemArea: 'Matemática' }, + ]; + const prova = { + categoria: { custom: false, nome: 'Enem Dia 2' }, + ano: 2023, + } as any; + const simulado: any = { nome: 'Enem Dia 2 2023' } as any; // === `${nome} ${ano}` + + const result = (service as any).selectQuestionsForSimulado( + simulado, + questoes, + prova, + { _id: 'fi' }, + { _id: 'fe' }, + ); + + expect(result).toHaveLength(2); + }); +}); diff --git a/src/modules/prova/prova.service.ts b/src/modules/prova/prova.service.ts index 87a6de5..2f27908 100644 --- a/src/modules/prova/prova.service.ts +++ b/src/modules/prova/prova.service.ts @@ -22,6 +22,7 @@ import { ProvaFactory } from './factory/prova_factory'; import { ProvaRepository } from './prova.repository'; import { Prova } from './prova.schema'; import { UpdateProvaFilesDTO } from './dtos/update-files.dto.input'; +import { atingiuQuantidade } from '../simulado/helpers/bloqueado'; @Injectable() export class ProvaService { @@ -39,10 +40,7 @@ export class ProvaService { public async create(item: CreateProvaDTOInput): Promise { const categoria = await this.categoriaRepository.getById(item.categoria); - const factory = this.provaFactory.getFactory( - categoria.exame as any, - item.ano, - ); + const factory = this.provaFactory.getFactory(categoria, item.ano); try { const prova = await factory.createProva(item); await factory.createSimulados(prova); @@ -117,9 +115,10 @@ export class ProvaService { ); if (!containsQuestion) return; - const hasRequiredCount = - simulado.questoes.length === - simulado.categoria.quantidadeTotalQuestao; + const hasRequiredCount = atingiuQuantidade( + simulado.categoria.quantidadeTotalQuestao, + simulado.questoes.length, + ); const allApproved = simulado.questoes.every( (q) => q.status === Status.Approved || q._id.toString() === questionId, @@ -154,9 +153,10 @@ export class ProvaService { if (!containsQuestion) return; // Recalcula bloqueado considerando a questão sendo rejeitada - const hasRequiredCount = - simulado.questoes.length === - simulado.categoria.quantidadeTotalQuestao; + const hasRequiredCount = atingiuQuantidade( + simulado.categoria.quantidadeTotalQuestao, + simulado.questoes.length, + ); const allApproved = hasRequiredCount && simulado.questoes.every((q) => { @@ -173,10 +173,7 @@ export class ProvaService { public async getMissingNumbers(id: string) { const prova = await this.repository.getProvaWithQuestion(id); - const factory = this.provaFactory.getFactory( - prova.categoria.exame as any, - prova.ano, - ); + const factory = this.provaFactory.getFactory(prova.categoria, prova.ano); return factory.getMissingNumbers(prova); } @@ -344,8 +341,10 @@ export class ProvaService { // C2: Recalcular bloqueado const hasRequiredCount = simulado.categoria - ? newSimQuestoes.length === - simulado.categoria.quantidadeTotalQuestao + ? atingiuQuantidade( + simulado.categoria.quantidadeTotalQuestao, + newSimQuestoes.length, + ) : false; const allApproved = newSimQuestoes.length > 0 && @@ -418,6 +417,12 @@ export class ProvaService { frenteIngles: Frente, frenteEspanhol: Frente, ): Questao[] { + // Prova custom: nome do simulado é livre, então não há string matching — + // todas as questões da prova entram no único simulado. + if (prova.categoria?.custom) { + return [...questoesDaProva]; + } + const nome = simulado.nome; const tipoNomeAno = prova.categoria ? `${prova.categoria.nome} ${prova.ano}` diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index cd55845..97cae35 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -47,7 +47,7 @@ export class QuestaoService { public async create(item: CreateQuestaoDTOInput): Promise { const prova = await this.provaRepository.getById(item.prova); - const factory = this.provaFactory.getFactory(prova.categoria.exame as any, prova.ano); + const factory = this.provaFactory.getFactory(prova.categoria, prova.ano); if (item.numero == null || await factory.verifyNumberProva(prova._id, item.numero)) { return await factory.createQuestion(item); } @@ -223,7 +223,7 @@ export class QuestaoService { throw new HttpException('Prova não informada', HttpStatus.BAD_REQUEST); } const prova = await this.provaRepository.getById(question.prova); - const factory = this.provaFactory.getFactory(prova.categoria.exame as any, prova.ano); + const factory = this.provaFactory.getFactory(prova.categoria, prova.ano); try { await factory.updateQuestion(question); } catch (error: any) { diff --git a/src/modules/simulado/helpers/bloqueado.spec.ts b/src/modules/simulado/helpers/bloqueado.spec.ts new file mode 100644 index 0000000..4b1b7b4 --- /dev/null +++ b/src/modules/simulado/helpers/bloqueado.spec.ts @@ -0,0 +1,17 @@ +import { atingiuQuantidade } from './bloqueado'; + +describe('atingiuQuantidade', () => { + it('retorna true quando a categoria é livre (null)', () => { + expect(atingiuQuantidade(null, 0)).toBe(true); + expect(atingiuQuantidade(null, 42)).toBe(true); + }); + + it('retorna true quando o total bate com o alvo numérico', () => { + expect(atingiuQuantidade(30, 30)).toBe(true); + }); + + it('retorna false quando o total não bate com o alvo numérico', () => { + expect(atingiuQuantidade(30, 29)).toBe(false); + expect(atingiuQuantidade(30, 31)).toBe(false); + }); +}); diff --git a/src/modules/simulado/helpers/bloqueado.ts b/src/modules/simulado/helpers/bloqueado.ts new file mode 100644 index 0000000..78bad01 --- /dev/null +++ b/src/modules/simulado/helpers/bloqueado.ts @@ -0,0 +1,15 @@ +/** + * Um simulado "atingiu a quantidade-alvo" quando a categoria é livre + * (`quantidadeTotalQuestao == null`, introduzida pelas categorias custom da + * etapa 3) OU quando o total de questões bate exatamente com o alvo numérico. + * + * Usado no cálculo de `Simulado.bloqueado` em vários pontos (addQuestionSimulados, + * approvedQuestion, refuseQuestion, executeSync). Antes desta regra, o `null` + * nunca satisfazia `length === null`, então simulados custom nunca desbloqueavam. + */ +export function atingiuQuantidade( + quantidadeTotalQuestao: number | null | undefined, + count: number, +): boolean { + return quantidadeTotalQuestao == null || count === quantidadeTotalQuestao; +} diff --git a/src/modules/simulado/schemas/simulado.schema.ts b/src/modules/simulado/schemas/simulado.schema.ts index c4ed65b..85caa7a 100644 --- a/src/modules/simulado/schemas/simulado.schema.ts +++ b/src/modules/simulado/schemas/simulado.schema.ts @@ -38,6 +38,14 @@ export class Simulado extends BaseSchema { @Prop({ required: false, default: true }) @ApiProperty() bloqueado?: boolean; + + @Prop({ required: false }) + @ApiProperty() + criadorId?: string; + + @Prop({ required: false, default: null }) + @ApiProperty() + cursinhoId?: string | null; } export const SimuladoSchema = SchemaFactory.createForClass(Simulado); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 7337ef0..6dd104e 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -16,6 +16,7 @@ import { Status } from '../questao/enums/status.enum'; import { QuestaoRepository } from '../questao/questao.repository'; import { Questao } from '../questao/questao.schema'; import { CategoriaRepository } from '../categoria/categoria.repository'; +import { atingiuQuantidade } from './helpers/bloqueado'; import { AnswerSimuladoDto } from './dtos/answer-simulado.dto.input'; import { AvailableSimuladoDTOoutput } from './dtos/available-simulado.dto.output'; import { SimuladoAnswerDTOOutput } from './dtos/simulado-answer.dto.output'; @@ -67,8 +68,11 @@ export class SimuladoService { sml.questoes.push(question); // Verifica se o simulador atingiu a quantidade total de questões - const atingiuQuantidadeTotal = - sml.questoes.length === sml.categoria.quantidadeTotalQuestao; + // (categoria livre / quantidadeTotalQuestao null sempre "atinge") + const atingiuQuantidadeTotal = atingiuQuantidade( + sml.categoria.quantidadeTotalQuestao, + sml.questoes.length, + ); // Verifica se todas as questões estão aprovadas const todasAprovadas = sml.questoes.every( (q) => q.status === Status.Approved,