From ecdd8935176ec0d958afd43fff6e768f831c80aa Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Fri, 31 Jul 2026 21:23:10 -0300 Subject: [PATCH 1/8] =?UTF-8?q?feat(simulado):=20fun=C3=A7=C3=B5es=20puras?= =?UTF-8?q?=20de=20availability=20(isSimuladoAvailable=20+=20getAvailabili?= =?UTF-8?q?tyStatus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../simulado/helpers/availability.spec.ts | 118 ++++++++++++++++++ src/modules/simulado/helpers/availability.ts | 37 ++++++ 2 files changed, 155 insertions(+) create mode 100644 src/modules/simulado/helpers/availability.spec.ts create mode 100644 src/modules/simulado/helpers/availability.ts diff --git a/src/modules/simulado/helpers/availability.spec.ts b/src/modules/simulado/helpers/availability.spec.ts new file mode 100644 index 0000000..ef09f08 --- /dev/null +++ b/src/modules/simulado/helpers/availability.spec.ts @@ -0,0 +1,118 @@ +import { getAvailabilityStatus, isSimuladoAvailable } from './availability'; + +// Datas fixas de referência (UTC) para determinismo — sem depender do relógio real. +const DE = new Date('2026-01-10T00:00:00.000Z'); +const ATE = new Date('2026-01-20T00:00:00.000Z'); +const ANTES = new Date('2026-01-05T00:00:00.000Z'); +const DENTRO = new Date('2026-01-15T00:00:00.000Z'); +const DEPOIS = new Date('2026-01-25T00:00:00.000Z'); + +describe('isSimuladoAvailable', () => { + it('false quando bloqueado, independente da janela', () => { + expect(isSimuladoAvailable({ bloqueado: true }, DENTRO)).toBe(false); + expect( + isSimuladoAvailable( + { bloqueado: true, disponivelDe: DE, disponivelAte: ATE }, + DENTRO, + ), + ).toBe(false); + }); + + it('true sem janela (null/null) quando desbloqueado', () => { + expect( + isSimuladoAvailable( + { bloqueado: false, disponivelDe: null, disponivelAte: null }, + DENTRO, + ), + ).toBe(true); + }); + + it('true quando desbloqueado, sem janela', () => { + expect(isSimuladoAvailable({ bloqueado: false }, DENTRO)).toBe(true); + }); + + it('só de: false antes, true no limite e depois de disponivelDe', () => { + const s = { + bloqueado: false, + disponivelDe: DE, + disponivelAte: null, + } as any; + expect(isSimuladoAvailable(s, ANTES)).toBe(false); + expect(isSimuladoAvailable(s, DE)).toBe(true); // limite inclusivo + expect(isSimuladoAvailable(s, DEPOIS)).toBe(true); + }); + + it('só até: true antes e no limite, false depois de disponivelAte', () => { + const s = { + bloqueado: false, + disponivelDe: null, + disponivelAte: ATE, + } as any; + expect(isSimuladoAvailable(s, ANTES)).toBe(true); + expect(isSimuladoAvailable(s, ATE)).toBe(true); // limite inclusivo + expect(isSimuladoAvailable(s, DEPOIS)).toBe(false); + }); + + it('de + até: false antes, true dentro, false depois', () => { + const s = { bloqueado: false, disponivelDe: DE, disponivelAte: ATE }; + expect(isSimuladoAvailable(s, ANTES)).toBe(false); + expect(isSimuladoAvailable(s, DENTRO)).toBe(true); + expect(isSimuladoAvailable(s, DEPOIS)).toBe(false); + }); +}); + +describe('getAvailabilityStatus', () => { + it("'bloqueado' quando bloqueado, independente da janela", () => { + expect(getAvailabilityStatus({ bloqueado: true }, DENTRO)).toBe( + 'bloqueado', + ); + expect( + getAvailabilityStatus( + { bloqueado: true, disponivelDe: DE, disponivelAte: ATE }, + ANTES, + ), + ).toBe('bloqueado'); + }); + + it("'disponivel' sem janela quando desbloqueado", () => { + expect( + getAvailabilityStatus( + { bloqueado: false, disponivelDe: null, disponivelAte: null }, + DENTRO, + ), + ).toBe('disponivel'); + }); + + it("'antes_da_janela' quando now < disponivelDe", () => { + expect( + getAvailabilityStatus( + { bloqueado: false, disponivelDe: DE, disponivelAte: ATE }, + ANTES, + ), + ).toBe('antes_da_janela'); + }); + + it("'disponivel' quando dentro da janela", () => { + expect( + getAvailabilityStatus( + { bloqueado: false, disponivelDe: DE, disponivelAte: ATE }, + DENTRO, + ), + ).toBe('disponivel'); + }); + + it("'depois_da_janela' quando now > disponivelAte", () => { + expect( + getAvailabilityStatus( + { bloqueado: false, disponivelDe: DE, disponivelAte: ATE }, + DEPOIS, + ), + ).toBe('depois_da_janela'); + }); + + it('limites inclusivos: disponivel exatamente em disponivelDe e disponivelAte', () => { + const s = { bloqueado: false, disponivelDe: DE, disponivelAte: ATE }; + expect(getAvailabilityStatus(s, DE)).toBe('disponivel'); + expect(getAvailabilityStatus(s, ATE)).toBe('disponivel'); + }); +}); diff --git a/src/modules/simulado/helpers/availability.ts b/src/modules/simulado/helpers/availability.ts new file mode 100644 index 0000000..6f63a23 --- /dev/null +++ b/src/modules/simulado/helpers/availability.ts @@ -0,0 +1,37 @@ +import { Simulado } from '../schemas/simulado.schema'; + +/** + * Fonte única de verdade sobre "esse simulado está disponível agora?". + * Combina o gate existente `bloqueado` com a janela temporal opcional + * (`disponivelDe` / `disponivelAte`). Funções puras, sem acesso a banco. + */ +export type AvailabilityStatus = + | 'disponivel' // bloqueado=false + dentro da janela (ou sem janela) + | 'bloqueado' // questões pendentes + | 'antes_da_janela' // bloqueado=false, mas now < disponivelDe + | 'depois_da_janela'; // bloqueado=false, mas now > disponivelAte + +type AvailabilityInput = Pick< + Simulado, + 'bloqueado' | 'disponivelDe' | 'disponivelAte' +>; + +export function isSimuladoAvailable( + s: AvailabilityInput, + now: Date = new Date(), +): boolean { + if (s.bloqueado) return false; + if (s.disponivelDe && now < s.disponivelDe) return false; + if (s.disponivelAte && now > s.disponivelAte) return false; + return true; +} + +export function getAvailabilityStatus( + s: AvailabilityInput, + now: Date = new Date(), +): AvailabilityStatus { + if (s.bloqueado) return 'bloqueado'; + if (s.disponivelDe && now < s.disponivelDe) return 'antes_da_janela'; + if (s.disponivelAte && now > s.disponivelAte) return 'depois_da_janela'; + return 'disponivel'; +} From 1d51b693dc4abbad8abb4b5a278db77bdf689382 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Fri, 31 Jul 2026 21:23:13 -0300 Subject: [PATCH 2/8] =?UTF-8?q?feat(simulado):=20campos=20disponivelDe/dis?= =?UTF-8?q?ponivelAte=20+=20=C3=ADndice=20composto=20de=20availability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/simulado/schemas/simulado.schema.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/modules/simulado/schemas/simulado.schema.ts b/src/modules/simulado/schemas/simulado.schema.ts index 85caa7a..428578a 100644 --- a/src/modules/simulado/schemas/simulado.schema.ts +++ b/src/modules/simulado/schemas/simulado.schema.ts @@ -46,6 +46,23 @@ export class Simulado extends BaseSchema { @Prop({ required: false, default: null }) @ApiProperty() cursinhoId?: string | null; + + @Prop({ required: false, default: null }) + @ApiProperty({ required: false, nullable: true }) + disponivelDe?: Date | null; + + @Prop({ required: false, default: null }) + @ApiProperty({ required: false, nullable: true }) + disponivelAte?: Date | null; } export const SimuladoSchema = SchemaFactory.createForClass(Simulado); + +// Índice composto para o filtro de availability do getAvailable (Card 02). +// autoIndex (default true no MongooseModule) cria automaticamente no boot. +SimuladoSchema.index({ + categoria: 1, + bloqueado: 1, + disponivelDe: 1, + disponivelAte: 1, +}); From 945f8c8412960bea9e225a4c8503c66e6a67623b Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Fri, 31 Jul 2026 21:42:24 -0300 Subject: [PATCH 3/8] feat(simulado): DTO UpdateDisponibilidadeDTO --- .../dtos/update-disponibilidade.dto.input.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/modules/simulado/dtos/update-disponibilidade.dto.input.ts diff --git a/src/modules/simulado/dtos/update-disponibilidade.dto.input.ts b/src/modules/simulado/dtos/update-disponibilidade.dto.input.ts new file mode 100644 index 0000000..72a4f54 --- /dev/null +++ b/src/modules/simulado/dtos/update-disponibilidade.dto.input.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDate, IsOptional } from 'class-validator'; + +export class UpdateDisponibilidadeDTO { + @ApiProperty({ required: false, nullable: true, type: Date }) + @IsOptional() + @IsDate() + @Type(() => Date) + disponivelDe?: Date | null; + + @ApiProperty({ required: false, nullable: true, type: Date }) + @IsOptional() + @IsDate() + @Type(() => Date) + disponivelAte?: Date | null; +} From be6b08d330ec585558b19e904cf8ad2b540aaf26 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Fri, 31 Jul 2026 21:42:57 -0300 Subject: [PATCH 4/8] feat(simulado): repo updateDisponibilidade + filtro de janela em getAvailable --- .../simulado/simulado.repository.spec.ts | 52 +++++++++++++++++++ src/modules/simulado/simulado.repository.ts | 18 ++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/modules/simulado/simulado.repository.spec.ts b/src/modules/simulado/simulado.repository.spec.ts index cf6d7ea..7557e11 100644 --- a/src/modules/simulado/simulado.repository.spec.ts +++ b/src/modules/simulado/simulado.repository.spec.ts @@ -14,3 +14,55 @@ describe('SimuladoRepository.countByCategoria', () => { }); }); }); + +describe('SimuladoRepository.updateDisponibilidade', () => { + it('faz $set apenas com os campos fornecidos', async () => { + const updateOne = jest.fn().mockResolvedValue({ acknowledged: true }); + const repo = new SimuladoRepository({ updateOne } as any); + + const de = new Date('2026-01-10T00:00:00.000Z'); + await repo.updateDisponibilidade('sim-1', { disponivelDe: de }); + + expect(updateOne).toHaveBeenCalledWith( + { _id: 'sim-1' }, + { $set: { disponivelDe: de } }, + ); + }); + + it('propaga null explícito no $set (para limpar)', async () => { + const updateOne = jest.fn().mockResolvedValue({ acknowledged: true }); + const repo = new SimuladoRepository({ updateOne } as any); + + await repo.updateDisponibilidade('sim-1', { + disponivelDe: null, + disponivelAte: null, + }); + + expect(updateOne).toHaveBeenCalledWith( + { _id: 'sim-1' }, + { $set: { disponivelDe: null, disponivelAte: null } }, + ); + }); +}); + +describe('SimuladoRepository.getAvailable', () => { + it('filtra por categoria, desbloqueado e dentro da janela temporal', async () => { + const select = jest.fn().mockResolvedValue([{ _id: 's1', nome: 'S1' }]); + const find = jest.fn().mockReturnValue({ select }); + const repo = new SimuladoRepository({ find } as any); + + const result = await repo.getAvailable('cat-1'); + + expect(result).toEqual([{ _id: 's1', nome: 'S1' }]); + const filtro = find.mock.calls[0][0]; + expect(filtro.categoria).toBe('cat-1'); + expect(filtro.bloqueado).toBe(false); + // janela: disponivelDe null OU <= agora ; disponivelAte null OU >= agora + expect(filtro.$and).toHaveLength(2); + expect(filtro.$and[0].$or[0]).toEqual({ disponivelDe: null }); + expect(filtro.$and[0].$or[1].disponivelDe.$lte).toBeInstanceOf(Date); + expect(filtro.$and[1].$or[0]).toEqual({ disponivelAte: null }); + expect(filtro.$and[1].$or[1].disponivelAte.$gte).toBeInstanceOf(Date); + expect(select).toHaveBeenCalledWith(['nome', '_id']); + }); +}); diff --git a/src/modules/simulado/simulado.repository.ts b/src/modules/simulado/simulado.repository.ts index 326c40a..b217301 100644 --- a/src/modules/simulado/simulado.repository.ts +++ b/src/modules/simulado/simulado.repository.ts @@ -80,12 +80,26 @@ export class SimuladoRepository extends BaseRepository { } async getAvailable(categoriaId: string) { + const now = new Date(); return await this.model - .find() - .where({ categoria: categoriaId, bloqueado: false }) + .find({ + categoria: categoriaId, + bloqueado: false, + $and: [ + { $or: [{ disponivelDe: null }, { disponivelDe: { $lte: now } }] }, + { $or: [{ disponivelAte: null }, { disponivelAte: { $gte: now } }] }, + ], + }) .select(['nome', '_id']); } + async updateDisponibilidade( + id: string, + fields: { disponivelDe?: Date | null; disponivelAte?: Date | null }, + ) { + await this.model.updateOne({ _id: id }, { $set: fields }); + } + public removeDuplicatedSimulados( simuladosToCheck: Simulado[], simuladosToCompare: Simulado[], From a9cf3960d55fbb027b346ae5012c2ada1596b560 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Fri, 31 Jul 2026 21:44:01 -0300 Subject: [PATCH 5/8] =?UTF-8?q?feat(simulado):=20gate=20403=20em=20getToAn?= =?UTF-8?q?swer=20+=20updateDisponibilidade=20com=20valida=C3=A7=C3=A3o=20?= =?UTF-8?q?cross-field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/simulado/simulado.service.spec.ts | 164 ++++++++++++++++++ src/modules/simulado/simulado.service.ts | 93 ++++++++-- 2 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 src/modules/simulado/simulado.service.spec.ts diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts new file mode 100644 index 0000000..0e6255d --- /dev/null +++ b/src/modules/simulado/simulado.service.spec.ts @@ -0,0 +1,164 @@ +import { + BadRequestException, + HttpException, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { SimuladoService } from './simulado.service'; + +function makeService(simuladoRepo: Record) { + const service = new SimuladoService( + simuladoRepo as any, // simuladoRepository + {} as any, // questoesRepository + {} as any, // categoriaRepository + {} as any, // historicoRepository + {} as any, // materiaRepository + {} as any, // queueProducer + ); + return service; +} + +const DE = new Date('2026-01-10T00:00:00.000Z'); +const ATE = new Date('2026-01-20T00:00:00.000Z'); + +describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { + it('retorna null quando o simulado não existe', async () => { + const service = makeService({ + getById: jest.fn().mockResolvedValue(null), + }); + await expect(service.getToAnswer('x')).resolves.toBeNull(); + }); + + it('lança 403 com status "bloqueado" quando bloqueado', async () => { + const service = makeService({ + getById: jest.fn().mockResolvedValue({ bloqueado: true }), + }); + await expect(service.getToAnswer('x')).rejects.toBeInstanceOf( + HttpException, + ); + try { + await service.getToAnswer('x'); + } catch (e) { + expect((e as HttpException).getStatus()).toBe(HttpStatus.FORBIDDEN); + expect((e as HttpException).getResponse()).toMatchObject({ + status: 'bloqueado', + }); + } + }); + + it('lança 403 com status "antes_da_janela" quando antes da janela', async () => { + const service = makeService({ + getById: jest.fn().mockResolvedValue({ + bloqueado: false, + disponivelDe: new Date(Date.now() + 60 * 60 * 1000), + disponivelAte: null, + }), + }); + try { + await service.getToAnswer('x'); + fail('deveria ter lançado'); + } catch (e) { + expect((e as HttpException).getResponse()).toMatchObject({ + status: 'antes_da_janela', + }); + } + }); + + it('lança 403 com status "depois_da_janela" quando depois da janela', async () => { + const service = makeService({ + getById: jest.fn().mockResolvedValue({ + bloqueado: false, + disponivelDe: null, + disponivelAte: new Date(Date.now() - 60 * 60 * 1000), + }), + }); + try { + await service.getToAnswer('x'); + fail('deveria ter lançado'); + } catch (e) { + expect((e as HttpException).getResponse()).toMatchObject({ + status: 'depois_da_janela', + }); + } + }); + + it('retorna o simulado montado quando disponível (null/null)', async () => { + const simulado = { + _id: 's1', + nome: 'S', + descricao: 'd', + bloqueado: false, + disponivelDe: null, + disponivelAte: null, + categoria: { _id: 'c1', duracao: 60 }, + questoes: [], + } as any; + const service = makeService({ + getById: jest.fn().mockResolvedValue(simulado), + }); + const result = await service.getToAnswer('s1'); + expect(result).toMatchObject({ _id: 's1', nome: 'S', duracao: 60 }); + }); +}); + +describe('SimuladoService.updateDisponibilidade', () => { + it('lança NotFound quando o simulado não existe', async () => { + const service = makeService({ + getById: jest.fn().mockResolvedValue(null), + }); + await expect( + service.updateDisponibilidade('x', { disponivelDe: DE }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('lança 400 quando disponivelDe >= disponivelAte (ambos no dto)', async () => { + const service = makeService({ + getById: jest.fn().mockResolvedValue({ _id: 'x' }), + updateDisponibilidade: jest.fn(), + }); + await expect( + service.updateDisponibilidade('x', { + disponivelDe: ATE, + disponivelAte: DE, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('lança 400 ao patchar só disponivelDe se o disponivelAte existente ficar invertido', async () => { + const service = makeService({ + getById: jest.fn().mockResolvedValue({ _id: 'x', disponivelAte: DE }), + updateDisponibilidade: jest.fn(), + }); + await expect( + service.updateDisponibilidade('x', { disponivelDe: ATE }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('persiste só o campo enviado (omitido = inalterado)', async () => { + const updateDisponibilidade = jest.fn().mockResolvedValue(undefined); + const getById = jest + .fn() + .mockResolvedValue({ _id: 'x', disponivelDe: null, disponivelAte: null }); + const service = makeService({ getById, updateDisponibilidade }); + + await service.updateDisponibilidade('x', { disponivelAte: ATE }); + + expect(updateDisponibilidade).toHaveBeenCalledWith('x', { + disponivelAte: ATE, + }); + }); + + it('propaga null explícito para limpar', async () => { + const updateDisponibilidade = jest.fn().mockResolvedValue(undefined); + const getById = jest + .fn() + .mockResolvedValue({ _id: 'x', disponivelDe: DE, disponivelAte: ATE }); + const service = makeService({ getById, updateDisponibilidade }); + + await service.updateDisponibilidade('x', { disponivelDe: null }); + + expect(updateDisponibilidade).toHaveBeenCalledWith('x', { + disponivelDe: null, + }); + }); +}); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 6dd104e..884622e 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -1,4 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { ClientSession } from 'mongoose'; import { GetAllInput } from 'src/shared/base/interfaces/get-all.input'; import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; @@ -17,6 +23,11 @@ import { QuestaoRepository } from '../questao/questao.repository'; import { Questao } from '../questao/questao.schema'; import { CategoriaRepository } from '../categoria/categoria.repository'; import { atingiuQuantidade } from './helpers/bloqueado'; +import { + getAvailabilityStatus, + isSimuladoAvailable, +} from './helpers/availability'; +import { UpdateDisponibilidadeDTO } from './dtos/update-disponibilidade.dto.input'; import { AnswerSimuladoDto } from './dtos/answer-simulado.dto.input'; import { AvailableSimuladoDTOoutput } from './dtos/available-simulado.dto.output'; import { SimuladoAnswerDTOOutput } from './dtos/simulado-answer.dto.output'; @@ -50,6 +61,21 @@ export class SimuladoService { public async getToAnswer( simuladoId: string, ): Promise { + const simulado = await this.simuladoRepository.getById(simuladoId); + if (!simulado) return null; + + // Gate rodado ANTES do try/catch: o try engole erros e retorna null, + // então o 403 precisa ser lançado fora dele para propagar. + if (!isSimuladoAvailable(simulado)) { + throw new HttpException( + { + message: 'Simulado fora da janela de disponibilidade', + status: getAvailabilityStatus(simulado), + }, + HttpStatus.FORBIDDEN, + ); + } + try { return await this.GetSimulado(simuladoId); } catch (error) { @@ -57,6 +83,41 @@ export class SimuladoService { } } + public async updateDisponibilidade( + id: string, + dto: UpdateDisponibilidadeDTO, + ): Promise { + const simulado = await this.simuladoRepository.getById(id); + if (!simulado) throw new NotFoundException(); + + // Valida contra os valores FINAIS (existente mesclado com o dto), + // não só quando ambos vêm no payload — evita janela invertida + // criada ao patchar um campo só. + const finalDe = + dto.disponivelDe !== undefined ? dto.disponivelDe : simulado.disponivelDe; + const finalAte = + dto.disponivelAte !== undefined + ? dto.disponivelAte + : simulado.disponivelAte; + if (finalDe && finalAte && finalDe >= finalAte) { + throw new BadRequestException( + 'disponivelDe deve ser anterior a disponivelAte', + ); + } + + // PATCH parcial: só grava as chaves presentes no dto. + // Omitido = inalterado; null explícito = limpa. + const fields: { disponivelDe?: Date | null; disponivelAte?: Date | null } = + {}; + if (dto.disponivelDe !== undefined) fields.disponivelDe = dto.disponivelDe; + if (dto.disponivelAte !== undefined) { + fields.disponivelAte = dto.disponivelAte; + } + + await this.simuladoRepository.updateDisponibilidade(id, fields); + return this.simuladoRepository.getById(id); + } + public async addQuestionSimulados( simulados: Simulado[], question: Questao, @@ -132,20 +193,24 @@ export class SimuladoService { const historico = await this.historicoRepository.getById(histId); if (!historico?.rawRespostas) { - await this.historicoRepository.updateStatus(histId, HistoricoStatus.Failed); + await this.historicoRepository.updateStatus( + histId, + HistoricoStatus.Failed, + ); return; } - const simuladoId = (historico.simulado as any)?._id?.toString() - ?? historico.simulado.toString(); + const simuladoId = + (historico.simulado as any)?._id?.toString() ?? + historico.simulado.toString(); const simulado = await this.simuladoRepository.answer(simuladoId); const ano = ( await this.questoesRepository.getById(simulado.questoes[0]._id) ).prova.ano; - const respostasAproveitamento: RespostaAproveitamento[] = simulado.questoes.map( - (questao) => { + const respostasAproveitamento: RespostaAproveitamento[] = + simulado.questoes.map((questao) => { const resposta = historico.rawRespostas!.find( (r: any) => r.questao === questao._id.toString(), ); @@ -156,11 +221,14 @@ export class SimuladoService { materia: questao.materia, frente: questao.frente1, }; - }, - ); + }); - const aproveitamento = await this.criaAproveitamento(respostasAproveitamento); - await this.questoesRepository.updateQuestionAnswered(respostasAproveitamento); + const aproveitamento = await this.criaAproveitamento( + respostasAproveitamento, + ); + await this.questoesRepository.updateQuestionAnswered( + respostasAproveitamento, + ); await this.historicoRepository.completeProcessing(histId, { ano, @@ -173,7 +241,10 @@ export class SimuladoService { aproveitamento, }); } catch (err) { - await this.historicoRepository.updateStatus(histId, HistoricoStatus.Failed); + await this.historicoRepository.updateStatus( + histId, + HistoricoStatus.Failed, + ); throw err; } } From a567606f43e712d88ec7eaf770fd1091faffdde2 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Fri, 31 Jul 2026 21:44:24 -0300 Subject: [PATCH 6/8] feat(simulado): endpoint PATCH :id/disponibilidade --- src/modules/simulado/simulado.controller.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/modules/simulado/simulado.controller.ts b/src/modules/simulado/simulado.controller.ts index b4bfd9d..e83a953 100644 --- a/src/modules/simulado/simulado.controller.ts +++ b/src/modules/simulado/simulado.controller.ts @@ -5,6 +5,7 @@ import { Get, HttpCode, Param, + Patch, Post, Query, } from '@nestjs/common'; @@ -14,6 +15,7 @@ import { GetAllDtoOutput } from 'src/shared/dtos/get-all.dto.output'; import { AnswerSimuladoDto } from './dtos/answer-simulado.dto.input'; import { AvailableSimuladoDTOoutput } from './dtos/available-simulado.dto.output'; import { SimuladoAnswerDTOOutput } from './dtos/simulado-answer.dto.output'; +import { UpdateDisponibilidadeDTO } from './dtos/update-disponibilidade.dto.input'; import { Simulado } from './schemas/simulado.schema'; import { SimuladoService } from './simulado.service'; @@ -52,6 +54,20 @@ export class SimuladoController { return this.service.answer(answer); } + @Patch(':id/disponibilidade') + @ApiResponse({ + status: 200, + description: 'atualiza a janela de disponibilidade do simulado', + type: Simulado, + isArray: false, + }) + public async updateDisponibilidade( + @Param('id') id: string, + @Body() dto: UpdateDisponibilidadeDTO, + ): Promise { + return await this.service.updateDisponibilidade(id, dto); + } + @Get('available') public async getAvailable( @Query('tipo') tipo: string, From 869fd6350a701d64bad569cd6bfaa43997a5fd34 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Fri, 31 Jul 2026 21:45:24 -0300 Subject: [PATCH 7/8] chore(simulado): remove import morto de Historico em simulado.service --- src/modules/simulado/simulado.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 884622e..3c19a2c 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -11,7 +11,6 @@ import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; import { QueueProducer } from 'src/shared/modules/queue/queue.producer'; import { HistoricoRepository } from '../historico/historico.repository'; import { HistoricoStatus } from '../historico/enums/historico-status.enum'; -import { Historico } from '../historico/historico.schema'; import { AproveitamentoHistorico, MateriaAproveitamento, From b7c4a0a819ddc756721fbd9b18bafa130d56984e Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Fri, 31 Jul 2026 21:51:45 -0300 Subject: [PATCH 8/8] perf(simulado): leitura projetada no gate de getToAnswer (evita getById pesado duplicado) --- .../simulado/simulado.repository.spec.ts | 24 +++++++++++++++++++ src/modules/simulado/simulado.repository.ts | 8 +++++++ src/modules/simulado/simulado.service.spec.ts | 13 ++++++---- src/modules/simulado/simulado.service.ts | 3 ++- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/modules/simulado/simulado.repository.spec.ts b/src/modules/simulado/simulado.repository.spec.ts index 7557e11..69adca1 100644 --- a/src/modules/simulado/simulado.repository.spec.ts +++ b/src/modules/simulado/simulado.repository.spec.ts @@ -66,3 +66,27 @@ describe('SimuladoRepository.getAvailable', () => { expect(select).toHaveBeenCalledWith(['nome', '_id']); }); }); + +describe('SimuladoRepository.getAvailabilityById', () => { + it('lê só os campos de disponibilidade, sem populate', async () => { + const exec = jest.fn().mockResolvedValue({ + bloqueado: false, + disponivelDe: null, + disponivelAte: null, + }); + const lean = jest.fn().mockReturnValue({ exec }); + const select = jest.fn().mockReturnValue({ lean }); + const findById = jest.fn().mockReturnValue({ select }); + const repo = new SimuladoRepository({ findById } as any); + + const result = await repo.getAvailabilityById('sim-1'); + + expect(findById).toHaveBeenCalledWith('sim-1'); + expect(select).toHaveBeenCalledWith('bloqueado disponivelDe disponivelAte'); + expect(result).toEqual({ + bloqueado: false, + disponivelDe: null, + disponivelAte: null, + }); + }); +}); diff --git a/src/modules/simulado/simulado.repository.ts b/src/modules/simulado/simulado.repository.ts index b217301..9f6b1f9 100644 --- a/src/modules/simulado/simulado.repository.ts +++ b/src/modules/simulado/simulado.repository.ts @@ -100,6 +100,14 @@ export class SimuladoRepository extends BaseRepository { await this.model.updateOne({ _id: id }, { $set: fields }); } + async getAvailabilityById(id: string): Promise { + return await this.model + .findById(id) + .select('bloqueado disponivelDe disponivelAte') + .lean() + .exec(); + } + public removeDuplicatedSimulados( simuladosToCheck: Simulado[], simuladosToCompare: Simulado[], diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index 0e6255d..afcba82 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -24,14 +24,14 @@ const ATE = new Date('2026-01-20T00:00:00.000Z'); describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { it('retorna null quando o simulado não existe', async () => { const service = makeService({ - getById: jest.fn().mockResolvedValue(null), + getAvailabilityById: jest.fn().mockResolvedValue(null), }); await expect(service.getToAnswer('x')).resolves.toBeNull(); }); it('lança 403 com status "bloqueado" quando bloqueado', async () => { const service = makeService({ - getById: jest.fn().mockResolvedValue({ bloqueado: true }), + getAvailabilityById: jest.fn().mockResolvedValue({ bloqueado: true }), }); await expect(service.getToAnswer('x')).rejects.toBeInstanceOf( HttpException, @@ -48,7 +48,7 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { it('lança 403 com status "antes_da_janela" quando antes da janela', async () => { const service = makeService({ - getById: jest.fn().mockResolvedValue({ + getAvailabilityById: jest.fn().mockResolvedValue({ bloqueado: false, disponivelDe: new Date(Date.now() + 60 * 60 * 1000), disponivelAte: null, @@ -66,7 +66,7 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { it('lança 403 com status "depois_da_janela" quando depois da janela', async () => { const service = makeService({ - getById: jest.fn().mockResolvedValue({ + getAvailabilityById: jest.fn().mockResolvedValue({ bloqueado: false, disponivelDe: null, disponivelAte: new Date(Date.now() - 60 * 60 * 1000), @@ -94,6 +94,11 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { questoes: [], } as any; const service = makeService({ + getAvailabilityById: jest.fn().mockResolvedValue({ + bloqueado: false, + disponivelDe: null, + disponivelAte: null, + }), getById: jest.fn().mockResolvedValue(simulado), }); const result = await service.getToAnswer('s1'); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 3c19a2c..951d114 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -60,7 +60,8 @@ export class SimuladoService { public async getToAnswer( simuladoId: string, ): Promise { - const simulado = await this.simuladoRepository.getById(simuladoId); + const simulado = + await this.simuladoRepository.getAvailabilityById(simuladoId); if (!simulado) return null; // Gate rodado ANTES do try/catch: o try engole erros e retorna null,