From 0da9c2cb162a57b229c710f3adba3b1078496038 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sat, 1 Aug 2026 21:55:40 -0300 Subject: [PATCH 01/65] feat(prova): schema QuestaoNaContainer (subdoc questao+numero) Co-Authored-By: Claude Opus 4.8 --- .../prova/schemas/questao-na-container.schema.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/modules/prova/schemas/questao-na-container.schema.ts diff --git a/src/modules/prova/schemas/questao-na-container.schema.ts b/src/modules/prova/schemas/questao-na-container.schema.ts new file mode 100644 index 0000000..665166f --- /dev/null +++ b/src/modules/prova/schemas/questao-na-container.schema.ts @@ -0,0 +1,15 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { SchemaTypes } from 'mongoose'; +import type { Questao } from '../../questao/questao.schema'; + +@Schema({ _id: true, versionKey: false }) +export class QuestaoNaContainer { + @Prop({ type: SchemaTypes.ObjectId, ref: 'Questao', required: true }) + questao: Questao; + + @Prop({ required: true }) + numero: number; +} + +export const QuestaoNaContainerSchema = + SchemaFactory.createForClass(QuestaoNaContainer); From 458a931629188d54566672b6c7e561128551a43a Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sat, 1 Aug 2026 21:56:01 -0300 Subject: [PATCH 02/65] feat(prova): campo paralelo questoesNovo em Prova Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.schema.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/modules/prova/prova.schema.ts b/src/modules/prova/prova.schema.ts index 2116a1d..143cf67 100644 --- a/src/modules/prova/prova.schema.ts +++ b/src/modules/prova/prova.schema.ts @@ -6,6 +6,10 @@ import { Simulado } from '../simulado/schemas/simulado.schema'; import { Categoria } from '../categoria/schemas/categoria.schema'; import { CreateProvaDTOInput } from './dtos/create.dto.input'; import { Edicao } from './enums/edicao.enum'; +import { + QuestaoNaContainer, + QuestaoNaContainerSchema, +} from './schemas/questao-na-container.schema'; @Schema({ timestamps: true, versionKey: false }) export class Prova extends BaseSchema { @@ -21,6 +25,7 @@ export class Prova extends BaseSchema { this.cursinhoId = item.cursinhoId ?? null; this.simulados = []; this.questoes = []; + this.questoesNovo = []; } @Prop({ enum: Edicao }) @@ -47,6 +52,9 @@ export class Prova extends BaseSchema { }) questoes: Questao[]; + @Prop({ type: [QuestaoNaContainerSchema], default: [] }) + public questoesNovo: QuestaoNaContainer[]; + @Prop() public nome: string; From ac2fbac61b8369dd62b0daeb5386d83008deafb8 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sat, 1 Aug 2026 21:56:32 -0300 Subject: [PATCH 03/65] feat(simulado): campo paralelo questoesNovo em Simulado Co-Authored-By: Claude Opus 4.8 --- src/modules/simulado/schemas/simulado.schema.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/modules/simulado/schemas/simulado.schema.ts b/src/modules/simulado/schemas/simulado.schema.ts index 428578a..5210db5 100644 --- a/src/modules/simulado/schemas/simulado.schema.ts +++ b/src/modules/simulado/schemas/simulado.schema.ts @@ -4,6 +4,10 @@ import mongoose, { Types } from 'mongoose'; import { BaseSchema } from 'src/shared/base/base.schema'; import { Questao } from '../../questao/questao.schema'; import { Categoria } from '../../categoria/schemas/categoria.schema'; +import { + QuestaoNaContainer, + QuestaoNaContainerSchema, +} from '../../prova/schemas/questao-na-container.schema'; @Schema({ timestamps: true, versionKey: false }) export class Simulado extends BaseSchema { @@ -27,6 +31,10 @@ export class Simulado extends BaseSchema { @ApiProperty({ type: Questao, isArray: true }) questoes: Questao[]; + @Prop({ type: [QuestaoNaContainerSchema], default: [] }) + @ApiProperty({ type: QuestaoNaContainer, isArray: true, required: false }) + questoesNovo?: QuestaoNaContainer[]; + @Prop({ required: false, default: 0 }) @ApiProperty() aproveitamento?: number; From 53424e91e89d808cde23b1d30f3b324521b6696e Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 2 Aug 2026 02:02:15 -0300 Subject: [PATCH 04/65] feat(migration): script 0001 migrate.sh popula questoesNovo (bash/jq) Co-Authored-By: Claude Opus 4.8 --- .../migrate.sh | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh diff --git a/scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh b/scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh new file mode 100644 index 0000000..fc00c13 --- /dev/null +++ b/scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# Migração 0001 — Questão Multi-Prova (Fase 2). +# Popula Prova.questoesNovo e Simulado.questoesNovo a partir do modelo antigo +# (Questao.prova + Questao.numero). Arrays antigos ("questoes") intactos. +# +# Uso: bash migrate.sh [dir] (dir default = diretório atual) +# Entrada (mongoexport --jsonArray, Extended JSON): +# /questoes.json /provas.json /simulados.json +# Saída: +# /provas.out.json /simulados.out.json +# +# Idempotente: sobrescreve questoesNovo. Não conecta ao Mongo. +set -euo pipefail + +DIR="${1:-.}" +QUESTOES="$DIR/questoes.json" +PROVAS="$DIR/provas.json" +SIMULADOS="$DIR/simulados.json" + +for f in "$QUESTOES" "$PROVAS" "$SIMULADOS"; do + [ -f "$f" ] || { echo "❌ Arquivo não encontrado: $f" >&2; exit 1; } +done + +# Gera N ObjectIds (24-hex) como array JSON de strings. +gen_ids() { + n="$1" + if [ "$n" -eq 0 ]; then echo "[]"; return; fi + i=0 + while [ "$i" -lt "$n" ]; do + openssl rand -hex 12 + i=$((i + 1)) + done | jq -R . | jq -s . +} + +# ============ PROVA ============ +nq=$(jq '[.[] | select(.prova != null and (.numero != null))] | length' "$QUESTOES") +echo "[STEP 1] Questoes com prova+numero: $nq" >&2 +prova_ids=$(gen_ids "$nq") + +jq -n \ + --slurpfile questoes "$QUESTOES" \ + --slurpfile provas "$PROVAS" \ + --argjson idpool "$prova_ids" ' + ($questoes[0]) as $qs + | ($provas[0]) as $ps + | ( [ $qs[] + | select(.prova != null and (.numero != null)) + | { provaId: .prova["$oid"], questao: {"$oid": ._id["$oid"]}, numero: .numero } ] + | to_entries + | map(.value + { _id: {"$oid": $idpool[.key]} }) ) as $entries + | ( $entries + | group_by(.provaId) + | map({ key: .[0].provaId, value: (map({_id, questao, numero})) }) + | from_entries ) as $byProva + | $ps | map(.questoesNovo = ($byProva[._id["$oid"]] // [])) +' > "$DIR/provas.out.json" + +total_prova=$(jq '[.[] | .questoesNovo | length] | add // 0' "$DIR/provas.out.json") +echo "[STEP 2] Entries em provas.out.json: $total_prova" >&2 + +orfas=$(jq -n --slurpfile q "$QUESTOES" --slurpfile p "$PROVAS" ' + ($p[0] | map(._id["$oid"])) as $ids + | [ $q[0][] + | select(.prova != null and (.numero != null)) + | select((.prova["$oid"]) as $pid | ($ids | index($pid)) | not) ] | length') +echo "[STEP 2b] Questoes órfãs (prova fora do dump): $orfas" >&2 + +# ============ SIMULADO ============ +ns=$(jq -n --slurpfile q "$QUESTOES" --slurpfile s "$SIMULADOS" ' + ($q[0] | map(select(.numero != null)) | INDEX(._id["$oid"])) as $qidx + | [ $s[0][] | (.questoes // [])[] | .["$oid"] | select($qidx[.] != null) ] | length') +echo "[STEP 3] Refs de simulado com numero: $ns" >&2 +sim_ids=$(gen_ids "$ns") + +jq -n \ + --slurpfile questoes "$QUESTOES" \ + --slurpfile simulados "$SIMULADOS" \ + --argjson idpool "$sim_ids" ' + ($questoes[0] | map(select(.numero != null)) | INDEX(._id["$oid"])) as $qidx + | ($simulados[0]) as $ss + | ( [ $ss | to_entries[] + | .key as $si | .value as $sim + | ($sim.questoes // [])[] + | .["$oid"] as $ref + | select($qidx[$ref] != null) + | { si: $si, questao: {"$oid": $ref}, numero: $qidx[$ref].numero } ] + | to_entries + | map(.value + { _id: {"$oid": $idpool[.key]} }) ) as $entries + | ( $entries + | group_by(.si) + | map({ key: (.[0].si | tostring), value: (map({_id, questao, numero})) }) + | from_entries ) as $bySim + | [ $ss | to_entries[] + | .value + { questoesNovo: ($bySim[(.key | tostring)] // []) } ] +' > "$DIR/simulados.out.json" + +total_sim=$(jq '[.[] | .questoesNovo | length] | add // 0' "$DIR/simulados.out.json") +echo "[STEP 3b] Entries em simulados.out.json: $total_sim" >&2 + +echo "[DONE] provas.out.json ($total_prova entries), simulados.out.json ($total_sim entries)." >&2 From 7a8d0c8813a81f9d42ce384eba5d02dfac82d2bc Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 2 Aug 2026 02:02:48 -0300 Subject: [PATCH 05/65] =?UTF-8?q?feat(migration):=20script=200001=20valida?= =?UTF-8?q?te.sh=20(contagens=20+=20idiom=C3=A1ticas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../validate.sh | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 scripts/migrations/0001-questoes-em-multiplas-provas/validate.sh diff --git a/scripts/migrations/0001-questoes-em-multiplas-provas/validate.sh b/scripts/migrations/0001-questoes-em-multiplas-provas/validate.sh new file mode 100644 index 0000000..094c5fe --- /dev/null +++ b/scripts/migrations/0001-questoes-em-multiplas-provas/validate.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# Validação da migração 0001. Roda sobre os JSONs (input + output), sem Mongo. +# Uso: bash validate.sh [dir] (default = diretório atual) +# Requer: questoes.json, provas.out.json, simulados.out.json +# Exit 0 = ok, 1 = falha. +set -euo pipefail + +DIR="${1:-.}" +QUESTOES="$DIR/questoes.json" +PROVAS_OUT="$DIR/provas.out.json" +SIMULADOS_OUT="$DIR/simulados.out.json" + +for f in "$QUESTOES" "$PROVAS_OUT" "$SIMULADOS_OUT"; do + [ -f "$f" ] || { echo "❌ Arquivo não encontrado: $f" >&2; exit 1; } +done + +fail=0 + +# [1] Total estrito: sum(prova.questoesNovo) == count(questoes com prova+numero +# cuja prova EXISTE no dump). Órfãs (prova fora do dump) são descontadas — +# elas têm prova+numero mas corretamente não entram em nenhum questoesNovo. +expected=$(jq -n --slurpfile q "$QUESTOES" --slurpfile p "$PROVAS_OUT" ' + ($p[0] | map(._id["$oid"])) as $ids + | [ $q[0][] | select(.prova != null and (.numero != null)) + | select((.prova["$oid"]) as $pid | ($ids | index($pid))) ] | length') +got=$(jq '[.[] | .questoesNovo | length] | add // 0' "$PROVAS_OUT") +if [ "$expected" = "$got" ]; then + echo "✓ [1] Total prova entries = $got (esperado $expected)" +else + echo "✗ [1] Total prova entries = $got, esperado $expected" >&2; fail=1 +fi + +# [2] Idiomáticas preservadas: nº de grupos (prova,numero) com >=2 igual input vs output +exp_pairs=$(jq -n --slurpfile q "$QUESTOES" ' + [ $q[0][] | select(.prova != null and (.numero != null)) + | (.prova["$oid"] + "|" + (.numero | tostring)) ] + | group_by(.) | map(select(length >= 2)) | length') +got_pairs=$(jq ' + [ .[] | (.questoesNovo // []) + | group_by(.numero) | map(select(length >= 2)) | length ] | add // 0' "$PROVAS_OUT") +if [ "$exp_pairs" = "$got_pairs" ]; then + echo "✓ [2] Grupos idiomáticos (>=2 mesmo numero) = $got_pairs" +else + echo "✗ [2] Idiomáticas: esperado $exp_pairs grupos, obtido $got_pairs" >&2; fail=1 +fi + +# [3] Simulado: questoesNovo.length == count(refs com numero!=null) +sim_bad=$(jq -n --slurpfile q "$QUESTOES" --slurpfile s "$SIMULADOS_OUT" ' + ($q[0] | map(select(.numero != null)) | INDEX(._id["$oid"])) as $qidx + | [ $s[0][] + | ( [ (.questoes // [])[] | .["$oid"] | select($qidx[.] != null) ] | length ) as $exp + | select((.questoesNovo | length) != $exp) + | ._id["$oid"] ]') +sim_bad_count=$(echo "$sim_bad" | jq 'length') +if [ "$sim_bad_count" = "0" ]; then + echo "✓ [3] Todos os simulados com questoesNovo.length correto" +else + echo "✗ [3] $sim_bad_count simulados com contagem errada: $(echo "$sim_bad" | jq -c '.[0:5]')" >&2; fail=1 +fi + +# [4] Órfãs (warn, não falha) +orfas=$(jq -n --slurpfile q "$QUESTOES" --slurpfile p "$PROVAS_OUT" ' + ($p[0] | map(._id["$oid"])) as $ids + | [ $q[0][] | select(.prova != null and (.numero != null)) + | select((.prova["$oid"]) as $pid | ($ids | index($pid)) | not) ] | length') +echo "ℹ [4] Questoes órfãs (prova fora do dump): $orfas (warn)" + +if [ "$fail" -eq 0 ]; then + echo "✅ Validação OK"; exit 0 +else + echo "❌ Validação falhou"; exit 1 +fi From 5971d290da90874ec7fa78ff58567607a87049a7 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 2 Aug 2026 02:03:51 -0300 Subject: [PATCH 06/65] docs(migration): README (backup/export/run/reimport) + rollback do 0001 Co-Authored-By: Claude Opus 4.8 --- .../README.md | 75 +++++++++++++++++++ .../rollback.md | 27 +++++++ 2 files changed, 102 insertions(+) create mode 100644 scripts/migrations/0001-questoes-em-multiplas-provas/README.md create mode 100644 scripts/migrations/0001-questoes-em-multiplas-provas/rollback.md diff --git a/scripts/migrations/0001-questoes-em-multiplas-provas/README.md b/scripts/migrations/0001-questoes-em-multiplas-provas/README.md new file mode 100644 index 0000000..fb7fa78 --- /dev/null +++ b/scripts/migrations/0001-questoes-em-multiplas-provas/README.md @@ -0,0 +1,75 @@ +# Migração 0001 — Questão Multi-Prova (Fase 2) + +Popula `Prova.questoesNovo` e `Simulado.questoesNovo` a partir do modelo antigo +(`Questao.prova` + `Questao.numero`). Arrays antigos (`questoes`) permanecem +intactos — os dois coexistem após esta fase. Nenhum consumer lê `questoesNovo` +ainda (Fase 3+). Reversível (`$unset questoesNovo`). + +Os scripts NÃO conectam ao Mongo: operam sobre JSON exportado localmente. + +## Pré-requisitos + +`jq` (>=1.6), `openssl`, `bash`, e o Mongo Database Tools (`mongodump`, +`mongoexport`, `mongoimport`). `$MONGODB` = URI do banco alvo. + +## Procedimento (homol primeiro, depois prod) + +### 1. Backup obrigatório (restore safety) + +```bash +DATE=$(date +%Y%m%d-%H%M%S) +mongodump --uri="$MONGODB" --collection=questoes --out="./backup-$DATE/" +mongodump --uri="$MONGODB" --collection=provas --out="./backup-$DATE/" +mongodump --uri="$MONGODB" --collection=simulados --out="./backup-$DATE/" +du -sh "./backup-$DATE/" # confirmar tamanho não-zero +# subir ./backup-$DATE/ para R2/S3 com retenção >=90 dias; documentar o caminho no PR +``` + +Backup apenas destas 3 collections (NÃO `historico`). + +### 2. Exportar as collections (JSON array, Extended JSON relaxado = default) + +```bash +mongoexport --uri="$MONGODB" --collection=questoes --jsonArray --out=questoes.json +mongoexport --uri="$MONGODB" --collection=provas --jsonArray --out=provas.json +mongoexport --uri="$MONGODB" --collection=simulados --jsonArray --out=simulados.json +``` + +> Caso o modelo precise de outras collections no futuro, exportá-las aqui e +> ajustar o `migrate.sh`. Hoje só estas 3. + +### 3. Rodar a migração + +```bash +bash migrate.sh . # gera provas.out.json e simulados.out.json +bash validate.sh . # deve terminar com "✅ Validação OK" (exit 0) +``` + +Se `validate.sh` sair com código 1, NÃO reimportar — investigar os logs. + +### 4. Smoke em homol (antes de reimportar em prod) + +Reimporte (passo 5) em homol e confira no frontend — tudo ainda lê do array +antigo, então deve estar idêntico: +- DashProva abre; abrir 1 prova; abrir 1 simulado. +- Responder 1 simulado → histórico funciona. + +### 5. Reimportar (drop + import — backup já salvo) + +```bash +mongosh "$MONGODB" --eval 'db.provas.drop(); db.simulados.drop();' +mongoimport --uri="$MONGODB" --collection=provas --jsonArray --file=provas.out.json +mongoimport --uri="$MONGODB" --collection=simulados --jsonArray --file=simulados.out.json +``` + +`questoes` **não muda** nesta fase — não reimportar. + +### 6. Produção + +Repetir 1→5 em janela de manutenção (~10min), sem deploys/writes concorrentes. +Guardar logs (`bash migrate.sh . > migrate-prod-$DATE.log 2>&1`). Backup +obrigatório antes. Se algo falhar → `rollback.md`. + +## Idempotência + +`migrate.sh` sobrescreve `questoesNovo`; rodar 2x = mesmo resultado. diff --git a/scripts/migrations/0001-questoes-em-multiplas-provas/rollback.md b/scripts/migrations/0001-questoes-em-multiplas-provas/rollback.md new file mode 100644 index 0000000..47d6acb --- /dev/null +++ b/scripts/migrations/0001-questoes-em-multiplas-provas/rollback.md @@ -0,0 +1,27 @@ +# Rollback — Migração 0001 (Fase 2) + +O sistema continua lendo do array antigo (`questoes`), que permanece intacto. +Rollback é trivial nesta fase. + +## Se algo deu errado durante/logo após a migração + +```bash +mongosh "$MONGODB" --eval ' + db.provas.updateMany({}, { $unset: { questoesNovo: "" } }); + db.simulados.updateMany({}, { $unset: { questoesNovo: "" } }); +' +``` + +Reverter o PR do Card 01 (schemas) só se necessário. + +## Se a migração corrompeu dado antigo (não deveria — só adiciona campo) + +Restaurar do backup: + +```bash +mongorestore --uri="$MONGODB" --drop --collection=provas ./backup-//provas.bson +mongorestore --uri="$MONGODB" --drop --collection=simulados ./backup-//simulados.bson +mongorestore --uri="$MONGODB" --drop --collection=questoes ./backup-//questoes.bson +``` + +Backup fica arquivado por precaução (retenção >=90 dias). From a96db8fcf68d69d9d62b1f65fea29c16a963d097 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:26:55 -0300 Subject: [PATCH 07/65] feat(prova): helper single-write question-container (questoesNovo) Co-Authored-By: Claude Opus 4.8 --- .../question-container.helpers.spec.ts | 64 +++++++++++++++++++ .../helpers/question-container.helpers.ts | 36 +++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/modules/prova/helpers/question-container.helpers.spec.ts create mode 100644 src/modules/prova/helpers/question-container.helpers.ts diff --git a/src/modules/prova/helpers/question-container.helpers.spec.ts b/src/modules/prova/helpers/question-container.helpers.spec.ts new file mode 100644 index 0000000..56bbf57 --- /dev/null +++ b/src/modules/prova/helpers/question-container.helpers.spec.ts @@ -0,0 +1,64 @@ +import { Types } from 'mongoose'; +import { + addQuestaoToContainer, + removeQuestaoFromContainer, +} from './question-container.helpers'; + +describe('question-container.helpers', () => { + it('addQuestaoToContainer empurra { questao, numero } com o objeto completo', () => { + const container: any = { questoesNovo: [] }; + const questao: any = { _id: new Types.ObjectId(), numero: 7, status: 'approved' }; + + addQuestaoToContainer(container, questao); + + expect(container.questoesNovo).toHaveLength(1); + expect(container.questoesNovo[0].numero).toBe(7); + // objeto completo preservado (permite ler .status em memória) + expect(container.questoesNovo[0].questao).toBe(questao); + expect(container.questoesNovo[0].questao.status).toBe('approved'); + }); + + it('permite duas questões com o mesmo numero (idiomáticas ENEM, sem dedupe)', () => { + const container: any = { questoesNovo: [] }; + const q1: any = { _id: new Types.ObjectId(), numero: 1 }; + const q2: any = { _id: new Types.ObjectId(), numero: 1 }; + + addQuestaoToContainer(container, q1); + addQuestaoToContainer(container, q2); + + expect(container.questoesNovo).toHaveLength(2); + expect(container.questoesNovo.map((qc: any) => qc.numero)).toEqual([1, 1]); + }); + + it('removeQuestaoFromContainer remove por id quando questao é objeto completo', () => { + const id = new Types.ObjectId(); + const outro = new Types.ObjectId(); + const container: any = { + questoesNovo: [ + { questao: { _id: id }, numero: 1 }, + { questao: { _id: outro }, numero: 2 }, + ], + }; + + removeQuestaoFromContainer(container, id); + + expect(container.questoesNovo).toHaveLength(1); + expect(container.questoesNovo[0].questao._id).toBe(outro); + }); + + it('removeQuestaoFromContainer remove por id quando questao é ObjectId cru (ref não populada)', () => { + const id = new Types.ObjectId(); + const outro = new Types.ObjectId(); + const container: any = { + questoesNovo: [ + { questao: id, numero: 1 }, + { questao: outro, numero: 2 }, + ], + }; + + removeQuestaoFromContainer(container, id); + + expect(container.questoesNovo).toHaveLength(1); + expect(container.questoesNovo[0].questao).toBe(outro); + }); +}); diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts new file mode 100644 index 0000000..77ae97a --- /dev/null +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -0,0 +1,36 @@ +import { Types } from 'mongoose'; +import { Questao } from '../../questao/questao.schema'; +import { QuestaoNaContainer } from '../schemas/questao-na-container.schema'; + +interface QuestaoContainer { + questoesNovo: QuestaoNaContainer[]; +} + +/** + * Adiciona a questão ao container (Prova ou Simulado) escrevendo SÓ em + * `questoesNovo` (cutover, single-write). Guarda o objeto Questao completo — + * o Mongoose casta para `_id` na persistência, e em memória preserva a leitura + * de `qc.questao.status`/`.numero` usada nas factories/addQuestionSimulados. + */ +export function addQuestaoToContainer( + container: QuestaoContainer, + questao: Questao, +): void { + container.questoesNovo.push({ questao, numero: questao.numero }); +} + +/** + * Remove do container a entry cuja questão bate com `questaoId`. Normaliza o id + * para funcionar tanto com objeto completo (`qc.questao._id`) quanto com ref + * crua (`qc.questao` sendo o próprio ObjectId). + */ +export function removeQuestaoFromContainer( + container: QuestaoContainer, + questaoId: Types.ObjectId, +): void { + const idStr = questaoId.toString(); + container.questoesNovo = container.questoesNovo.filter((qc) => { + const cur = (qc.questao as any)?._id ?? qc.questao; + return cur.toString() !== idStr; + }); +} From 5014316756845887c1a8cc2a17439ce1abadd807 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:30:57 -0300 Subject: [PATCH 08/65] =?UTF-8?q?feat(prova):=20repository=20l=C3=AA/escre?= =?UTF-8?q?ve=20questoesNovo=20(getById=20populate=20+=20add/removeQuestio?= =?UTF-8?q?n=20single-write)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.repository.spec.ts | 98 ++++++++++++++++++++++ src/modules/prova/prova.repository.ts | 28 ++++--- 2 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 src/modules/prova/prova.repository.spec.ts diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts new file mode 100644 index 0000000..ab05d33 --- /dev/null +++ b/src/modules/prova/prova.repository.spec.ts @@ -0,0 +1,98 @@ +import { Types } from 'mongoose'; +import { Status } from '../questao/enums/status.enum'; +import { ProvaRepository } from './prova.repository'; + +describe('ProvaRepository.addQuestion (single-write questoesNovo)', () => { + it('empurra a questão em questoesNovo e incrementa totalQuestaoValidadas quando aprovada', async () => { + const prova: any = { + _id: 'p1', + questoes: [], + questoesNovo: [], + totalQuestaoValidadas: 0, + }; + const updateOne = jest.fn().mockResolvedValue({ acknowledged: true }); + const findById = jest.fn().mockResolvedValue(prova); + const repo = new ProvaRepository({ findById, updateOne } as any); + + const questao: any = { + _id: new Types.ObjectId(), + numero: 3, + status: Status.Approved, + }; + await repo.addQuestion('p1', questao); + + expect(prova.questoesNovo).toHaveLength(1); + expect(prova.questoesNovo[0].numero).toBe(3); + expect(prova.questoes).toHaveLength(0); // array antigo congelado + expect(prova.totalQuestaoValidadas).toBe(1); + expect(updateOne).toHaveBeenCalledWith({ _id: 'p1' }, prova); + }); + + it('não incrementa totalQuestaoValidadas quando a questão não está aprovada', async () => { + const prova: any = { + _id: 'p1', + questoes: [], + questoesNovo: [], + totalQuestaoValidadas: 0, + }; + const findById = jest.fn().mockResolvedValue(prova); + const repo = new ProvaRepository({ + findById, + updateOne: jest.fn().mockResolvedValue({}), + } as any); + + await repo.addQuestion('p1', { + _id: new Types.ObjectId(), + numero: 1, + status: Status.Pending, + } as any); + + expect(prova.totalQuestaoValidadas).toBe(0); + }); +}); + +describe('ProvaRepository.removeQuestion (single-write questoesNovo)', () => { + it('remove de questoesNovo e decrementa totalQuestaoValidadas quando aprovada', async () => { + const alvo = new Types.ObjectId(); + const prova: any = { + _id: 'p1', + questoes: [], + questoesNovo: [{ questao: { _id: alvo }, numero: 1 }], + totalQuestaoValidadas: 1, + }; + const updateOne = jest.fn().mockResolvedValue({ acknowledged: true }); + const findById = jest.fn().mockResolvedValue(prova); + const repo = new ProvaRepository({ findById, updateOne } as any); + + await repo.removeQuestion('p1', { + _id: alvo, + status: Status.Approved, + } as any); + + expect(prova.questoesNovo).toHaveLength(0); + expect(prova.totalQuestaoValidadas).toBe(0); + expect(updateOne).toHaveBeenCalledWith({ _id: 'p1' }, prova); + }); + + it('não decrementa quando a questão não estava no container', async () => { + const prova: any = { + _id: 'p1', + questoes: [], + questoesNovo: [{ questao: { _id: new Types.ObjectId() }, numero: 1 }], + totalQuestaoValidadas: 1, + }; + const findById = jest.fn().mockResolvedValue(prova); + const repo = new ProvaRepository({ + findById, + updateOne: jest.fn().mockResolvedValue({}), + } as any); + + await repo.removeQuestion('p1', { + _id: new Types.ObjectId(), + status: Status.Approved, + } as any); + + expect(prova.questoesNovo).toHaveLength(1); + expect(prova.totalQuestaoValidadas).toBe(1); + }); +}); diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index 664499d..021fadd 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -1,11 +1,15 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; +import { Model, Types } from 'mongoose'; import { BaseRepository } from 'src/shared/base/base.repository'; import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; import { GetAllWhereInput } from 'src/shared/base/interfaces/get-all.input'; import { Status } from '../questao/enums/status.enum'; import { Questao } from '../questao/questao.schema'; +import { + addQuestaoToContainer, + removeQuestaoFromContainer, +} from './helpers/question-container.helpers'; import { Prova } from './prova.schema'; @Injectable() @@ -30,16 +34,17 @@ export class ProvaRepository extends BaseRepository { return await this.model .findById(id) .populate(['simulados', 'questoes']) + .populate('questoesNovo.questao') .populate({ path: 'categoria', populate: 'exame' }) .populate({ path: 'simulados', - populate: ['categoria', 'questoes'], + populate: ['categoria', 'questoes', { path: 'questoesNovo.questao' }], }); } public async addQuestion(id: string, question: Questao) { const prova = await this.model.findById(id); - prova.questoes.push(question); + addQuestaoToContainer(prova, question); if (question.status === Status.Approved) { prova.totalQuestaoValidadas += 1; } @@ -48,16 +53,15 @@ export class ProvaRepository extends BaseRepository { public async removeQuestion(id: string, oldQuestao: Questao) { const prova = await this.model.findById(id); - const index = prova.questoes.findIndex( - (questao) => questao._id.toString() === oldQuestao._id.toString(), - ); - if (index !== -1) { - prova.questoes.splice(index, 1); - if (oldQuestao.status === Status.Approved) { - prova.totalQuestaoValidadas -= 1; - } - await this.model.updateOne({ _id: prova._id }, prova); + const before = prova.questoesNovo.length; + removeQuestaoFromContainer(prova, oldQuestao._id as unknown as Types.ObjectId); + if ( + prova.questoesNovo.length !== before && + oldQuestao.status === Status.Approved + ) { + prova.totalQuestaoValidadas -= 1; } + await this.model.updateOne({ _id: prova._id }, prova); } async getAll({ From dd33c160e4436538d769d5b699b0990248dd0849 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:32:32 -0300 Subject: [PATCH 09/65] refactor(prova): helper aceita ObjectId|string (remove cast no removeQuestion) Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/helpers/question-container.helpers.ts | 2 +- src/modules/prova/prova.repository.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts index 77ae97a..6da9c83 100644 --- a/src/modules/prova/helpers/question-container.helpers.ts +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -26,7 +26,7 @@ export function addQuestaoToContainer( */ export function removeQuestaoFromContainer( container: QuestaoContainer, - questaoId: Types.ObjectId, + questaoId: Types.ObjectId | string, ): void { const idStr = questaoId.toString(); container.questoesNovo = container.questoesNovo.filter((qc) => { diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index 021fadd..dacb3f4 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model, Types } from 'mongoose'; +import { Model } from 'mongoose'; import { BaseRepository } from 'src/shared/base/base.repository'; import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; import { GetAllWhereInput } from 'src/shared/base/interfaces/get-all.input'; @@ -54,7 +54,7 @@ export class ProvaRepository extends BaseRepository { public async removeQuestion(id: string, oldQuestao: Questao) { const prova = await this.model.findById(id); const before = prova.questoesNovo.length; - removeQuestaoFromContainer(prova, oldQuestao._id as unknown as Types.ObjectId); + removeQuestaoFromContainer(prova, oldQuestao._id); if ( prova.questoesNovo.length !== before && oldQuestao.status === Status.Approved From 7d67fdb2023451b9aa786d72c947ebaa6c3986df Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:36:07 -0300 Subject: [PATCH 10/65] feat(simulado): repository popula questoesNovo.questao (answer substitui, getById adiciona) Co-Authored-By: Claude Opus 4.8 --- .../simulado/simulado.repository.spec.ts | 18 ++++++++++++++++++ src/modules/simulado/simulado.repository.ts | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/modules/simulado/simulado.repository.spec.ts b/src/modules/simulado/simulado.repository.spec.ts index 69adca1..9e68966 100644 --- a/src/modules/simulado/simulado.repository.spec.ts +++ b/src/modules/simulado/simulado.repository.spec.ts @@ -90,3 +90,21 @@ describe('SimuladoRepository.getAvailabilityById', () => { }); }); }); + +describe('SimuladoRepository.answer (popula questoesNovo.questao)', () => { + it('popula questoesNovo.questao com frente1/materia e alternativa', async () => { + const exec = jest.fn().mockResolvedValue({ _id: 's1', questoesNovo: [] }); + const populate = jest.fn().mockReturnValue({ exec }); + const findById = jest.fn().mockReturnValue({ populate }); + const repo = new SimuladoRepository({ findById } as any); + + await repo.answer('s1'); + + expect(findById).toHaveBeenCalledWith('s1'); + expect(populate).toHaveBeenCalledWith({ + path: 'questoesNovo.questao', + populate: ['frente1', 'materia'], + select: 'alternativa', + }); + }); +}); diff --git a/src/modules/simulado/simulado.repository.ts b/src/modules/simulado/simulado.repository.ts index 9f6b1f9..52148f9 100644 --- a/src/modules/simulado/simulado.repository.ts +++ b/src/modules/simulado/simulado.repository.ts @@ -27,6 +27,10 @@ export class SimuladoRepository extends BaseRepository { path: 'questoes', populate: ['frente1', 'materia'], }) + .populate({ + path: 'questoesNovo.questao', + populate: ['frente1', 'materia'], + }) .exec(); } @@ -44,7 +48,7 @@ export class SimuladoRepository extends BaseRepository { return await this.model .findById(id) .populate({ - path: 'questoes', + path: 'questoesNovo.questao', populate: ['frente1', 'materia'], select: 'alternativa', }) From 9b289124850e32f45cdb111c27e7ecb335edb215 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:40:56 -0300 Subject: [PATCH 11/65] feat(simulado): add/removeQuestionSimulados single-write em questoesNovo Co-Authored-By: Claude Opus 4.8 --- src/modules/simulado/simulado.service.spec.ts | 81 +++++++++++++++++++ src/modules/simulado/simulado.service.ts | 33 +++++--- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index afcba82..15dbb77 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { SimuladoService } from './simulado.service'; +import { Status } from '../questao/enums/status.enum'; function makeService(simuladoRepo: Record) { const service = new SimuladoService( @@ -167,3 +168,83 @@ describe('SimuladoService.updateDisponibilidade', () => { }); }); }); + +describe('SimuladoService.addQuestionSimulados (single-write questoesNovo)', () => { + it('empurra em questoesNovo e desbloqueia quando atinge quantidade e todas aprovadas', async () => { + const updateSession = jest.fn().mockResolvedValue(undefined); + const service = makeService({ updateSession }); + const sml: any = { + _id: 's1', + questoes: [], + questoesNovo: [], + categoria: { quantidadeTotalQuestao: 1 }, + bloqueado: true, + }; + const question: any = { + _id: 'q1', + numero: 1, + status: Status.Approved, + }; + + await service.addQuestionSimulados([sml], question); + + expect(sml.questoesNovo).toHaveLength(1); + expect(sml.questoes).toHaveLength(0); // congelado + expect(sml.bloqueado).toBe(false); + expect(updateSession).toHaveBeenCalledWith(sml, undefined); + }); + + it('mantém bloqueado quando ainda não atingiu a quantidade', async () => { + const service = makeService({ + updateSession: jest.fn().mockResolvedValue(undefined), + }); + const sml: any = { + _id: 's1', + questoesNovo: [], + categoria: { quantidadeTotalQuestao: 30 }, + bloqueado: true, + }; + + await service.addQuestionSimulados([sml], { + _id: 'q1', + numero: 1, + status: Status.Approved, + } as any); + + expect(sml.bloqueado).toBe(true); + }); +}); + +describe('SimuladoService.removeQuestionSimulados (single-write questoesNovo)', () => { + it('remove de questoesNovo e bloqueia o simulado', async () => { + const updateSession = jest.fn().mockResolvedValue(undefined); + const service = makeService({ updateSession }); + const sml: any = { + _id: 's1', + questoesNovo: [{ questao: { _id: 'q1' }, numero: 1 }], + bloqueado: false, + }; + + await service.removeQuestionSimulados([sml], { _id: 'q1' } as any); + + expect(sml.questoesNovo).toHaveLength(0); + expect(sml.bloqueado).toBe(true); + expect(updateSession).toHaveBeenCalledWith(sml, undefined); + }); + + it('não mexe quando a questão não estava no simulado', async () => { + const updateSession = jest.fn().mockResolvedValue(undefined); + const service = makeService({ updateSession }); + const sml: any = { + _id: 's1', + questoesNovo: [{ questao: { _id: 'outra' }, numero: 1 }], + bloqueado: false, + }; + + await service.removeQuestionSimulados([sml], { _id: 'q1' } as any); + + expect(sml.questoesNovo).toHaveLength(1); + expect(sml.bloqueado).toBe(false); + expect(updateSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 951d114..f1b3e06 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -22,6 +22,10 @@ import { QuestaoRepository } from '../questao/questao.repository'; import { Questao } from '../questao/questao.schema'; import { CategoriaRepository } from '../categoria/categoria.repository'; import { atingiuQuantidade } from './helpers/bloqueado'; +import { + addQuestaoToContainer, + removeQuestaoFromContainer, +} from '../prova/helpers/question-container.helpers'; import { getAvailabilityStatus, isSimuladoAvailable, @@ -125,24 +129,28 @@ export class SimuladoService { ) { await Promise.all( simulados.map(async (sml) => { - // Adiciona a nova questão - sml.questoes.push(question); + // Adiciona a nova questão (single-write em questoesNovo). + // `questoesNovo` é opcional no tipo Simulado mas sempre existe em + // runtime (default: [] no schema); cast p/ satisfazer o helper. + addQuestaoToContainer( + sml as Required>, + question, + ); - // Verifica se o simulador atingiu a quantidade total de questões + // Verifica se o simulado atingiu a quantidade total de questões // (categoria livre / quantidadeTotalQuestao null sempre "atinge") const atingiuQuantidadeTotal = atingiuQuantidade( sml.categoria.quantidadeTotalQuestao, - sml.questoes.length, + sml.questoesNovo!.length, ); // Verifica se todas as questões estão aprovadas - const todasAprovadas = sml.questoes.every( - (q) => q.status === Status.Approved, + const todasAprovadas = sml.questoesNovo!.every( + (qc) => qc.questao.status === Status.Approved, ); - // Define bloqueado como false somente se todas as questões foram adicionadas e estão aprovadas + // Bloqueado = false só se todas adicionadas e aprovadas sml.bloqueado = !(atingiuQuantidadeTotal && todasAprovadas); - // Retorna a promessa para o update return await this.simuladoRepository.updateSession(sml, session); }), ); @@ -155,11 +163,12 @@ export class SimuladoService { ) { await Promise.all( simulados.map(async (sml) => { - const index = sml.questoes.findIndex( - (questao) => questao._id.toString() === question._id.toString(), + const before = sml.questoesNovo!.length; + removeQuestaoFromContainer( + sml as Required>, + question._id, ); - if (index !== -1) { - sml.questoes.splice(index, 1); + if (sml.questoesNovo!.length !== before) { sml.bloqueado = true; await this.simuladoRepository.updateSession(sml, session); } From dcb01dd0790c122d2b6b365c2e25d3a16043f750 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:44:46 -0300 Subject: [PATCH 12/65] =?UTF-8?q?refactor(simulado):=20questoesNovo=20n?= =?UTF-8?q?=C3=A3o-opcional=20(alinha=20com=20Prova,=20remove=20casts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../prova/factory/enem_2010_2016_factory.ts | 1 + .../prova/factory/enem_2017_plus_factory.ts | 1 + src/modules/prova/services/enem_service.ts | 2 ++ .../simulado/schemas/simulado.schema.ts | 2 +- src/modules/simulado/simulado.service.ts | 20 ++++++------------- 5 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index b98312a..c2983d2 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -71,6 +71,7 @@ export class Enem2010_2017Factory implements IProvaFactory { nome: `${mainName}`, categoria: prova.categoria, questoes: [], + questoesNovo: [], descricao: `${prova.categoria.exame.nome}`, criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index 2fd5a45..6f59143 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -78,6 +78,7 @@ export class Enem2017PlusFactory implements IProvaFactory { nome: `${mainName}`, categoria: prova.categoria, questoes: [], + questoesNovo: [], descricao: `${prova.categoria.exame.nome}`, criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, diff --git a/src/modules/prova/services/enem_service.ts b/src/modules/prova/services/enem_service.ts index 399f729..fc8898e 100644 --- a/src/modules/prova/services/enem_service.ts +++ b/src/modules/prova/services/enem_service.ts @@ -25,6 +25,7 @@ export class EnemService { nome: `${mainName} Inglês`, categoria: prova.categoria, questoes: [], + questoesNovo: [], descricao: `${prova.categoria.exame.nome} Inglês`, criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, @@ -35,6 +36,7 @@ export class EnemService { nome: `${mainName} Espanhol`, categoria: prova.categoria, questoes: [], + questoesNovo: [], descricao: `${prova.categoria.exame.nome} Espanhol`, criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, diff --git a/src/modules/simulado/schemas/simulado.schema.ts b/src/modules/simulado/schemas/simulado.schema.ts index 5210db5..ae5deaf 100644 --- a/src/modules/simulado/schemas/simulado.schema.ts +++ b/src/modules/simulado/schemas/simulado.schema.ts @@ -33,7 +33,7 @@ export class Simulado extends BaseSchema { @Prop({ type: [QuestaoNaContainerSchema], default: [] }) @ApiProperty({ type: QuestaoNaContainer, isArray: true, required: false }) - questoesNovo?: QuestaoNaContainer[]; + questoesNovo: QuestaoNaContainer[]; @Prop({ required: false, default: 0 }) @ApiProperty() diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index f1b3e06..7a394f3 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -130,21 +130,16 @@ export class SimuladoService { await Promise.all( simulados.map(async (sml) => { // Adiciona a nova questão (single-write em questoesNovo). - // `questoesNovo` é opcional no tipo Simulado mas sempre existe em - // runtime (default: [] no schema); cast p/ satisfazer o helper. - addQuestaoToContainer( - sml as Required>, - question, - ); + addQuestaoToContainer(sml, question); // Verifica se o simulado atingiu a quantidade total de questões // (categoria livre / quantidadeTotalQuestao null sempre "atinge") const atingiuQuantidadeTotal = atingiuQuantidade( sml.categoria.quantidadeTotalQuestao, - sml.questoesNovo!.length, + sml.questoesNovo.length, ); // Verifica se todas as questões estão aprovadas - const todasAprovadas = sml.questoesNovo!.every( + const todasAprovadas = sml.questoesNovo.every( (qc) => qc.questao.status === Status.Approved, ); @@ -163,12 +158,9 @@ export class SimuladoService { ) { await Promise.all( simulados.map(async (sml) => { - const before = sml.questoesNovo!.length; - removeQuestaoFromContainer( - sml as Required>, - question._id, - ); - if (sml.questoesNovo!.length !== before) { + const before = sml.questoesNovo.length; + removeQuestaoFromContainer(sml, question._id); + if (sml.questoesNovo.length !== before) { sml.bloqueado = true; await this.simuladoRepository.updateSession(sml, session); } From 8bee0316b1a28795607b3307a44c697443d859a2 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:50:58 -0300 Subject: [PATCH 13/65] feat(prova): approved/refuseQuestion leem questoesNovo + contagem por questoesNovo Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.service.spec.ts | 10 +++--- src/modules/prova/prova.service.ts | 45 +++++++++++++------------ 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts index ca17e79..adb7923 100644 --- a/src/modules/prova/prova.service.spec.ts +++ b/src/modules/prova/prova.service.spec.ts @@ -22,12 +22,12 @@ 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 }], + questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], categoria: { quantidadeTotalQuestao: null }, bloqueado: true, }; const prova = { - questoes: [{ _id: 'q1', status: Status.Pending }], + questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], simulados: [simulado], }; const { service } = makeService({ @@ -42,12 +42,12 @@ describe('ProvaService.approvedQuestion — regra bloqueado com qtd null', () => 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 }], + questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], categoria: { quantidadeTotalQuestao: 30 }, bloqueado: true, }; const prova = { - questoes: [{ _id: 'q1', status: Status.Pending }], + questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], simulados: [simulado], }; const { service } = makeService({ @@ -120,7 +120,7 @@ describe('ProvaService.getAllByCursinho', () => { totalQuestaoValidadas: 0, filename: 'f.pdf', enemAreas: [] as any[], - questoes: [] as any[], + questoesNovo: [] as any[], createdAt: new Date('2024-01-01T00:00:00.000Z'), }; } diff --git a/src/modules/prova/prova.service.ts b/src/modules/prova/prova.service.ts index 779d0b9..ea804ae 100644 --- a/src/modules/prova/prova.service.ts +++ b/src/modules/prova/prova.service.ts @@ -55,7 +55,7 @@ export class ProvaService { exame: result.categoria.exame.nome, nome: result.nome, totalQuestao: result.totalQuestao, - totalQuestaoCadastradas: result.questoes.length, + totalQuestaoCadastradas: result.questoesNovo.length, totalQuestaoValidadas: result.totalQuestaoValidadas, filename: result.filename, gabarito: result.gabarito, @@ -86,7 +86,7 @@ export class ProvaService { totalQuestaoValidadas: prova.totalQuestaoValidadas, filename: prova.filename, enemAreas: prova.enemAreas, - totalQuestaoCadastradas: prova.questoes.length, + totalQuestaoCadastradas: prova.questoesNovo.length, createdAt: prova.createdAt, } as GetProvaDTOOutout; } @@ -119,30 +119,31 @@ export class ProvaService { const prova = await this.repository.getById(id); // Recalcula totalQuestaoValidadas contando a questão sendo aprovada - prova.totalQuestaoValidadas = prova.questoes.filter((q) => { - if (q._id.toString() === questionId) return true; - return q.status === Status.Approved; + prova.totalQuestaoValidadas = prova.questoesNovo.filter((qc) => { + if (qc.questao._id.toString() === questionId) return true; + return qc.questao.status === Status.Approved; }).length; await Promise.all( prova.simulados.map(async (simulado) => { - const containsQuestion = simulado.questoes.find( - (q) => q._id.toString() === questionId, + const containsQuestion = simulado.questoesNovo.find( + (qc) => qc.questao._id.toString() === questionId, ); if (!containsQuestion) return; const hasRequiredCount = atingiuQuantidade( simulado.categoria.quantidadeTotalQuestao, - simulado.questoes.length, + simulado.questoesNovo.length, ); - const allApproved = simulado.questoes.every( - (q) => - q.status === Status.Approved || q._id.toString() === questionId, + const allApproved = simulado.questoesNovo.every( + (qc) => + qc.questao.status === Status.Approved || + qc.questao._id.toString() === questionId, ); simulado.bloqueado = !(hasRequiredCount && allApproved); if (!simulado.bloqueado) { - simulado.questoes = simulado.questoes.sort( + simulado.questoesNovo = simulado.questoesNovo.sort( (a, b) => a.numero - b.numero, ); } @@ -156,28 +157,28 @@ export class ProvaService { const prova = await this.repository.getById(id); // Recalcula totalQuestaoValidadas excluindo a questão sendo rejeitada - prova.totalQuestaoValidadas = prova.questoes.filter((q) => { - if (q._id.toString() === questionId) return false; - return q.status === Status.Approved; + prova.totalQuestaoValidadas = prova.questoesNovo.filter((qc) => { + if (qc.questao._id.toString() === questionId) return false; + return qc.questao.status === Status.Approved; }).length; await Promise.all( prova.simulados.map(async (simulado) => { - const containsQuestion = simulado.questoes.some( - (q) => q._id.toString() === questionId, + const containsQuestion = simulado.questoesNovo.some( + (qc) => qc.questao._id.toString() === questionId, ); if (!containsQuestion) return; // Recalcula bloqueado considerando a questão sendo rejeitada const hasRequiredCount = atingiuQuantidade( simulado.categoria.quantidadeTotalQuestao, - simulado.questoes.length, + simulado.questoesNovo.length, ); const allApproved = hasRequiredCount && - simulado.questoes.every((q) => { - if (q._id.toString() === questionId) return false; - return q.status === Status.Approved; + simulado.questoesNovo.every((qc) => { + if (qc.questao._id.toString() === questionId) return false; + return qc.questao.status === Status.Approved; }); simulado.bloqueado = !allApproved; @@ -522,7 +523,7 @@ export class ProvaService { exame: prova.categoria.exame.nome, nome: prova.nome, totalQuestao: prova.totalQuestao, - totalQuestaoCadastradas: prova.questoes.length, + totalQuestaoCadastradas: prova.questoesNovo.length, totalQuestaoValidadas: prova.totalQuestaoValidadas, filename: prova.filename, gabarito: prova.gabarito, From 94593588a3a0a0837a565fafec5fa5af1cfaf56f Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:55:02 -0300 Subject: [PATCH 14/65] test(prova): cobre totalQuestaoValidadas + refuseQuestion (questoesNovo) Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.service.spec.ts | 36 ++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts index adb7923..994da72 100644 --- a/src/modules/prova/prova.service.spec.ts +++ b/src/modules/prova/prova.service.spec.ts @@ -29,7 +29,7 @@ describe('ProvaService.approvedQuestion — regra bloqueado com qtd null', () => const prova = { questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], simulados: [simulado], - }; + } as any; const { service } = makeService({ getById: jest.fn().mockResolvedValue(prova), }); @@ -37,6 +37,7 @@ describe('ProvaService.approvedQuestion — regra bloqueado com qtd null', () => await service.approvedQuestion('p1', 'q1'); expect(simulado.bloqueado).toBe(false); + expect(prova.totalQuestaoValidadas).toBe(1); }); it('mantém bloqueado quando categoria numérica ainda não atingiu a quantidade', async () => { @@ -182,3 +183,36 @@ describe('ProvaService.getAllByCursinho', () => { expect(result.totalItems).toBe(0); }); }); + +describe('ProvaService.refuseQuestion — recompute questoesNovo', () => { + it('exclui a questão recusada da contagem e bloqueia o simulado', async () => { + const simulado: any = { + _id: 's1', + questoesNovo: [ + { questao: { _id: 'q1', status: Status.Approved }, numero: 1 }, + { questao: { _id: 'q2', status: Status.Approved }, numero: 2 }, + ], + categoria: { quantidadeTotalQuestao: 2 }, + bloqueado: false, + }; + const prova = { + questoesNovo: [ + { questao: { _id: 'q1', status: Status.Approved }, numero: 1 }, + { questao: { _id: 'q2', status: Status.Approved }, numero: 2 }, + ], + simulados: [simulado], + } as any; + const { service, simuladoRepository, repository } = makeService({ + getById: jest.fn().mockResolvedValue(prova), + }); + + await service.refuseQuestion('p1', 'q1'); + + // q1 recusada não conta; sobra q2 aprovada + expect(prova.totalQuestaoValidadas).toBe(1); + // simulado tinha q1 → recalcula bloqueado (q1 excluída => nem todas aprovadas) + expect(simulado.bloqueado).toBe(true); + expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); + expect(repository.update).toHaveBeenCalledWith(prova); + }); +}); From 1f409f7462c0a4714438abfd21acf30242e650d5 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 14:56:38 -0300 Subject: [PATCH 15/65] =?UTF-8?q?feat(simulado):=20processAnswer=20l=C3=AA?= =?UTF-8?q?=20questoesNovo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/modules/simulado/simulado.service.spec.ts | 52 +++++++++++++++++++ src/modules/simulado/simulado.service.ts | 7 ++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index 15dbb77..55d3f29 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -248,3 +248,55 @@ describe('SimuladoService.removeQuestionSimulados (single-write questoesNovo)', expect(updateSession).not.toHaveBeenCalled(); }); }); + +describe('SimuladoService.processAnswer (lê questoesNovo)', () => { + it('mapeia respostas a partir de questoesNovo e completa o histórico', async () => { + const questao: any = { + _id: { toString: () => 'q1' }, + alternativa: 'A', + materia: { _id: { toString: () => 'm1' }, nome: 'Mat' }, + frente1: { _id: { toString: () => 'f1' }, nome: 'Fr' }, + }; + const simulado: any = { + _id: 's1', + questoesNovo: [{ questao, numero: 1 }], + }; + + const historicoRepository: any = { + claimForProcessing: jest.fn().mockResolvedValue(true), + getById: jest.fn().mockResolvedValue({ + simulado: 's1', + rawRespostas: [{ questao: 'q1', alternativaEstudante: 'A' }], + }), + updateStatus: jest.fn().mockResolvedValue(undefined), + completeProcessing: jest.fn().mockResolvedValue(undefined), + }; + const simuladoRepository: any = { + answer: jest.fn().mockResolvedValue(simulado), + }; + const questoesRepository: any = { + getById: jest.fn().mockResolvedValue({ prova: { ano: 2023 } }), + updateQuestionAnswered: jest.fn().mockResolvedValue(undefined), + }; + + const service = new SimuladoService( + simuladoRepository, + questoesRepository, + {} as any, // categoriaRepository + historicoRepository, + {} as any, // materiaRepository + {} as any, // queueProducer + ); + + await service.processAnswer('hist1'); + + // buscou o ano pela primeira questão de questoesNovo + expect(questoesRepository.getById).toHaveBeenCalledWith(questao._id); + // completou com 1 resposta mapeada de questoesNovo + const payload = historicoRepository.completeProcessing.mock.calls[0][1]; + expect(payload.ano).toBe(2023); + expect(payload.respostas).toHaveLength(1); + expect(payload.respostas[0].alternativaEstudante).toBe('A'); + expect(payload.respostas[0].alternativaCorreta).toBe('A'); + }); +}); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 7a394f3..2681895 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -207,11 +207,14 @@ export class SimuladoService { const simulado = await this.simuladoRepository.answer(simuladoId); const ano = ( - await this.questoesRepository.getById(simulado.questoes[0]._id) + await this.questoesRepository.getById( + simulado.questoesNovo[0].questao._id, + ) ).prova.ano; const respostasAproveitamento: RespostaAproveitamento[] = - simulado.questoes.map((questao) => { + simulado.questoesNovo.map((qc) => { + const questao = qc.questao; const resposta = historico.rawRespostas!.find( (r: any) => r.questao === questao._id.toString(), ); From 71a49ec1d3866c80eef4589429dc8dbdc6f04449 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 15:06:15 -0300 Subject: [PATCH 16/65] =?UTF-8?q?fix(historico):=20totalQuestionsTest=20l?= =?UTF-8?q?=C3=AA=20questoesNovo=20(dashboard=20p=C3=B3s-cutover)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/modules/historico/historico.service.spec.ts | 3 ++- src/modules/historico/historico.service.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/historico/historico.service.spec.ts b/src/modules/historico/historico.service.spec.ts index 2ee51d6..b499989 100644 --- a/src/modules/historico/historico.service.spec.ts +++ b/src/modules/historico/historico.service.spec.ts @@ -30,7 +30,7 @@ function makeHistorico(aproveitamento: AproveitamentoHistorico) { _id: 'hist-id', simulado: { nome: 'Simulado', - questoes: [] as any[], + questoesNovo: [{}, {}] as any[], aproveitamento: 0, vezesRespondido: 0, }, @@ -67,5 +67,6 @@ describe('HistoricoService.calcularMediaAproveitamento', () => { const expected = (0.5 + 0.8 + 1.0) / 3; // ≈ 0.7667 expect(result.performanceMateriaFrente.materias[0].aproveitamento).toBeCloseTo(expected, 5); expect(result.performanceMateriaFrente.frentes[0].aproveitamento).toBeCloseTo(expected, 5); + expect(result.historicos[0].totalQuestionsTest).toBe(2); }); }); diff --git a/src/modules/historico/historico.service.ts b/src/modules/historico/historico.service.ts index 232acaa..bd610d6 100644 --- a/src/modules/historico/historico.service.ts +++ b/src/modules/historico/historico.service.ts @@ -32,7 +32,7 @@ export class HistoricoService { performance: historico.aproveitamento, timeSpent: historico.tempoRealizado, questionsAnswered: historico.questoesRespondidas, - totalQuestionsTest: historico.simulado.questoes.length, + totalQuestionsTest: historico.simulado.questoesNovo.length, testPerformance: historico.simulado.aproveitamento, testAttempts: historico.simulado.vezesRespondido, createdAt: historico.createdAt, From 5199af7762b35ea187a9d2fcfe903fadb15b072c Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 15:06:15 -0300 Subject: [PATCH 17/65] test(prova): fixa populate de questoesNovo.questao no getById Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.repository.spec.ts | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts index ab05d33..efea09e 100644 --- a/src/modules/prova/prova.repository.spec.ts +++ b/src/modules/prova/prova.repository.spec.ts @@ -96,3 +96,32 @@ describe('ProvaRepository.removeQuestion (single-write questoesNovo)', () => { expect(prova.totalQuestaoValidadas).toBe(1); }); }); + +describe('ProvaRepository.getById (popula questoesNovo.questao)', () => { + it('popula questoesNovo.questao no topo e aninhado nos simulados', async () => { + const populateCalls: any[] = []; + const query: any = {}; + query.populate = jest.fn((arg: any) => { + populateCalls.push(arg); + return query; + }); + // torna a query "thenable" (getById faz await sem .exec()) + query.then = (resolve: any) => resolve({ _id: 'p1' }); + const findById = jest.fn().mockReturnValue(query); + const repo = new ProvaRepository({ findById } as any); + + await repo.getById('p1'); + + expect(findById).toHaveBeenCalledWith('p1'); + // populate top-level de questoesNovo.questao + expect(populateCalls).toContain('questoesNovo.questao'); + // populate aninhado nos simulados inclui questoesNovo.questao + const nested = populateCalls.find( + (c) => c && typeof c === 'object' && c.path === 'simulados', + ); + expect(nested).toBeDefined(); + expect(nested.populate).toEqual( + expect.arrayContaining([{ path: 'questoesNovo.questao' }]), + ); + }); +}); From 4ed5d0dbeaa815566a3960aa785eada85a4a4bfc Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 17:06:05 -0300 Subject: [PATCH 18/65] feat(prova): helper updateNumeroNoContainer (numero-sync cutover) Co-Authored-By: Claude Opus 4.8 --- .../question-container.helpers.spec.ts | 54 +++++++++++++++++++ .../helpers/question-container.helpers.ts | 23 ++++++++ 2 files changed, 77 insertions(+) diff --git a/src/modules/prova/helpers/question-container.helpers.spec.ts b/src/modules/prova/helpers/question-container.helpers.spec.ts index 56bbf57..1e04d0a 100644 --- a/src/modules/prova/helpers/question-container.helpers.spec.ts +++ b/src/modules/prova/helpers/question-container.helpers.spec.ts @@ -2,6 +2,7 @@ import { Types } from 'mongoose'; import { addQuestaoToContainer, removeQuestaoFromContainer, + updateNumeroNoContainer, } from './question-container.helpers'; describe('question-container.helpers', () => { @@ -62,3 +63,56 @@ describe('question-container.helpers', () => { expect(container.questoesNovo[0].questao).toBe(outro); }); }); + +describe('updateNumeroNoContainer', () => { + it('atualiza o numero da entry correta e retorna true (questao objeto completo)', () => { + const alvo = new Types.ObjectId(); + const outro = new Types.ObjectId(); + const container: any = { + questoesNovo: [ + { questao: { _id: alvo }, numero: 5 }, + { questao: { _id: outro }, numero: 6 }, + ], + }; + + const changed = updateNumeroNoContainer(container, alvo, 42); + + expect(changed).toBe(true); + expect(container.questoesNovo[0].numero).toBe(42); + expect(container.questoesNovo[1].numero).toBe(6); + }); + + it('funciona com questao sendo ObjectId cru (ref não populada)', () => { + const alvo = new Types.ObjectId(); + const container: any = { + questoesNovo: [{ questao: alvo, numero: 5 }], + }; + + const changed = updateNumeroNoContainer(container, alvo, 9); + + expect(changed).toBe(true); + expect(container.questoesNovo[0].numero).toBe(9); + }); + + it('retorna false quando o numero já é o esperado (no-op)', () => { + const alvo = new Types.ObjectId(); + const container: any = { + questoesNovo: [{ questao: { _id: alvo }, numero: 7 }], + }; + + const changed = updateNumeroNoContainer(container, alvo, 7); + + expect(changed).toBe(false); + expect(container.questoesNovo[0].numero).toBe(7); + }); + + it('retorna false quando a questão não está no container', () => { + const container: any = { + questoesNovo: [{ questao: { _id: new Types.ObjectId() }, numero: 1 }], + }; + + const changed = updateNumeroNoContainer(container, new Types.ObjectId(), 3); + + expect(changed).toBe(false); + }); +}); diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts index 6da9c83..27157ca 100644 --- a/src/modules/prova/helpers/question-container.helpers.ts +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -34,3 +34,26 @@ export function removeQuestaoFromContainer( return cur.toString() !== idStr; }); } + +/** + * Atualiza in-place o `numero` das entries que referenciam `questaoId`. + * Usado no cutover: `numero` vive no subdoc, então editar o número de uma + * questão exige reconciliar os containers (prova + simulados). Retorna se + * alguma entry mudou (pra evitar persistência desnecessária). + */ +export function updateNumeroNoContainer( + container: QuestaoContainer, + questaoId: Types.ObjectId | string, + numero: number, +): boolean { + const idStr = questaoId.toString(); + let changed = false; + for (const qc of container.questoesNovo) { + const cur = (qc.questao as any)?._id ?? qc.questao; + if (cur.toString() === idStr && qc.numero !== numero) { + qc.numero = numero; + changed = true; + } + } + return changed; +} From 5e9e2efa7866fcad9afc040b9e6239905ba7d685 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 17:09:04 -0300 Subject: [PATCH 19/65] refactor(prova): getProvaWithQuestion/getAllPopulated sem populate de questoes (cutover) Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.repository.spec.ts | 47 ++++++++++++++++++++++ src/modules/prova/prova.repository.ts | 4 +- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts index efea09e..5c95ddb 100644 --- a/src/modules/prova/prova.repository.spec.ts +++ b/src/modules/prova/prova.repository.spec.ts @@ -125,3 +125,50 @@ describe('ProvaRepository.getById (popula questoesNovo.questao)', () => { ); }); }); + +describe('ProvaRepository.getProvaWithQuestion (sem populate de questoes)', () => { + it('popula categoria/exame e NÃO popula questoes', async () => { + const populateArgs: any[] = []; + const exec = jest.fn().mockResolvedValue({ _id: 'p1' }); + const query: any = { exec }; + query.populate = jest.fn((arg: any) => { + populateArgs.push(arg); + return query; + }); + const findById = jest.fn().mockReturnValue(query); + const repo = new ProvaRepository({ findById } as any); + + await repo.getProvaWithQuestion('p1'); + + expect(findById).toHaveBeenCalledWith('p1'); + expect(populateArgs).not.toContain('questoes'); + expect( + populateArgs.some( + (a) => a && typeof a === 'object' && a.path === 'categoria', + ), + ).toBe(true); + }); +}); + +describe('ProvaRepository.getAllPopulated (sem populate de questoes)', () => { + it('popula categoria e simulados.categoria, NÃO popula questoes', async () => { + const populateArgs: any[] = []; + const exec = jest.fn().mockResolvedValue([]); + const query: any = { exec }; + query.find = jest.fn().mockReturnValue(query); + query.populate = jest.fn((arg: any) => { + populateArgs.push(arg); + return query; + }); + const repo = new ProvaRepository({ find: query.find } as any); + + await repo.getAllPopulated(); + + expect(populateArgs).not.toContain('questoes'); + const simuladosPop = populateArgs.find( + (a) => a && typeof a === 'object' && a.path === 'simulados', + ); + expect(simuladosPop).toBeDefined(); + expect(simuladosPop.populate).toEqual(['categoria']); + }); +}); diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index dacb3f4..6134a29 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -25,7 +25,6 @@ export class ProvaRepository extends BaseRepository { async getProvaWithQuestion(id: string): Promise { return await this.model .findById(id) - .populate('questoes') .populate({ path: 'categoria', populate: 'exame' }) .exec(); } @@ -86,9 +85,8 @@ export class ProvaRepository extends BaseRepository { .populate({ path: 'categoria', populate: 'exame' }) .populate({ path: 'simulados', - populate: ['categoria', 'questoes'], + populate: ['categoria'], }) - .populate('questoes') .exec(); } From 16d1d57dd2ed313b818076dcc5cb60bbb30707e7 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 17:15:29 -0300 Subject: [PATCH 20/65] feat(prova): Enem2017PlusFactory getMissingNumbers/verifyNumberProva leem questoesNovo (Regras A/C) Co-Authored-By: Claude Opus 4.8 --- .../factory/enem_2017_plus_factory.spec.ts | 89 +++++++++++++++++++ .../prova/factory/enem_2017_plus_factory.ts | 11 ++- 2 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 src/modules/prova/factory/enem_2017_plus_factory.spec.ts diff --git a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts new file mode 100644 index 0000000..eccf42f --- /dev/null +++ b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts @@ -0,0 +1,89 @@ +import { Enem2017PlusFactory } from './enem_2017_plus_factory'; + +function makeFactory(getProvaWithQuestion?: jest.Mock) { + const provaRepository = { + getProvaWithQuestion: getProvaWithQuestion ?? jest.fn(), + }; + const factory = new Enem2017PlusFactory( + {} as any, // categoriaRepository + {} as any, // questaoRepository + provaRepository as any, + {} as any, // frenteRepository + {} as any, // simuladoService + {} as any, // simuladoRepository + {} as any, // enemService + ); + return { factory, provaRepository }; +} + +describe('Enem2017PlusFactory.getMissingNumbers (Regra A — Dia 1, idiomáticas 1-5)', () => { + it('marca como faltante a idiomática (1-5) que só tem 1 ocorrência', async () => { + const questoesNovo: any[] = []; + for (let n = 1; n <= 90; n++) questoesNovo.push({ numero: n, questao: {} }); + questoesNovo.push({ numero: 1, questao: {} }); // numero 1 agora tem 2 + + const { factory } = makeFactory(); + const missing = await factory.getMissingNumbers({ + nome: 'Enem Dia 1 2020 PRIMEIRA 1', + inicialNumero: 1, + questoesNovo, + } as any); + + expect(missing).toEqual([2, 3, 4, 5]); + }); + + it('marca numero totalmente ausente como faltante', async () => { + const questoesNovo: any[] = []; + for (let n = 1; n <= 90; n++) { + if (n === 50) continue; + questoesNovo.push({ numero: n, questao: {} }); + if (n < 6) questoesNovo.push({ numero: n, questao: {} }); // 1-5 completas + } + const { factory } = makeFactory(); + const missing = await factory.getMissingNumbers({ + nome: 'Enem Dia 1 2020 PRIMEIRA 1', + inicialNumero: 1, + questoesNovo, + } as any); + expect(missing).toEqual([50]); + }); +}); + +describe('Enem2017PlusFactory.verifyNumberProva (Regra C)', () => { + it('numero livre → true', async () => { + const { factory } = makeFactory( + jest.fn().mockResolvedValue({ questoesNovo: [] }), + ); + expect(await factory.verifyNumberProva('p1', 50)).toBe(true); + }); + + it('idiomático (1-5) com 1 ocorrência → true (cabe o 2º)', async () => { + const { factory } = makeFactory( + jest + .fn() + .mockResolvedValue({ questoesNovo: [{ numero: 3, questao: {} }] }), + ); + expect(await factory.verifyNumberProva('p1', 3)).toBe(true); + }); + + it('idiomático (1-5) com 2 ocorrências → false', async () => { + const { factory } = makeFactory( + jest.fn().mockResolvedValue({ + questoesNovo: [ + { numero: 3, questao: {} }, + { numero: 3, questao: {} }, + ], + }), + ); + expect(await factory.verifyNumberProva('p1', 3)).toBe(false); + }); + + it('não-idiomático ocupado → false', async () => { + const { factory } = makeFactory( + jest + .fn() + .mockResolvedValue({ questoesNovo: [{ numero: 10, questao: {} }] }), + ); + expect(await factory.verifyNumberProva('p1', 10)).toBe(false); + }); +}); diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index 6f59143..fac1a98 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -91,13 +91,12 @@ export class Enem2017PlusFactory implements IProvaFactory { const missingQuestion = []; if (day1) { for (let index = prova.inicialNumero; index <= 90; index++) { - if (!prova.questoes.find((quest) => quest.numero === index)) { + if (!prova.questoesNovo.find((qc) => qc.numero === index)) { missingQuestion.push(index); } else { if (index < 6) { const hasAllQuestion = - prova.questoes.filter((quest) => quest.numero === index).length < - 2; + prova.questoesNovo.filter((qc) => qc.numero === index).length < 2; if (hasAllQuestion) { missingQuestion.push(index); } @@ -106,7 +105,7 @@ export class Enem2017PlusFactory implements IProvaFactory { } } else { for (let index = prova.inicialNumero; index <= 180; index++) { - if (!prova.questoes.find((quest) => quest.numero === index)) { + if (!prova.questoesNovo.find((qc) => qc.numero === index)) { missingQuestion.push(index); } } @@ -119,10 +118,10 @@ export class Enem2017PlusFactory implements IProvaFactory { numberQuestion: number, ): Promise { const prova = await this.provaRepository.getProvaWithQuestion(id); - if (prova.questoes.some((quest) => quest.numero === numberQuestion)) { + if (prova.questoesNovo.some((qc) => qc.numero === numberQuestion)) { if (numberQuestion < 6) { return ( - prova.questoes.filter((quest) => quest.numero === numberQuestion) + prova.questoesNovo.filter((qc) => qc.numero === numberQuestion) .length < 2 ); } From bf306e683fc516a1b71ebe61c42c8852f5927e8c Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 17:19:43 -0300 Subject: [PATCH 21/65] feat(prova): Enem2010_2016Factory getMissingNumbers/verifyNumberProva leem questoesNovo (Regras B/C) Co-Authored-By: Claude Opus 4.8 --- .../factory/enem_2010_2016_factory.spec.ts | 73 +++++++++++++++++++ .../prova/factory/enem_2010_2016_factory.ts | 11 ++- 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 src/modules/prova/factory/enem_2010_2016_factory.spec.ts diff --git a/src/modules/prova/factory/enem_2010_2016_factory.spec.ts b/src/modules/prova/factory/enem_2010_2016_factory.spec.ts new file mode 100644 index 0000000..b12a75d --- /dev/null +++ b/src/modules/prova/factory/enem_2010_2016_factory.spec.ts @@ -0,0 +1,73 @@ +import { Enem2010_2017Factory } from './enem_2010_2016_factory'; + +function makeFactory(getProvaWithQuestion?: jest.Mock) { + const provaRepository = { + getProvaWithQuestion: getProvaWithQuestion ?? jest.fn(), + }; + const factory = new Enem2010_2017Factory( + {} as any, // categoriaRepository + {} as any, // questaoRepository + provaRepository as any, + {} as any, // frenteRepository + {} as any, // simuladoService + {} as any, // simuladoRepository + {} as any, // enemService + ); + return { factory, provaRepository }; +} + +describe('Enem2010_2017Factory.getMissingNumbers (Regra B — Dia 2, idiomáticas 91-95)', () => { + it('marca como faltante a idiomática (91-95) que só tem 1 ocorrência', async () => { + const questoesNovo: any[] = []; + for (let n = 91; n <= 180; n++) questoesNovo.push({ numero: n, questao: {} }); + questoesNovo.push({ numero: 91, questao: {} }); // 91 agora com 2 + + const { factory } = makeFactory(); + const missing = await factory.getMissingNumbers({ + nome: 'Enem Dia 2 2015 PRIMEIRA 1', + inicialNumero: 91, + questoesNovo, + } as any); + + expect(missing).toEqual([92, 93, 94, 95]); + }); +}); + +describe('Enem2010_2017Factory.verifyNumberProva (Regra C — idiomáticas 91-95)', () => { + it('numero livre → true', async () => { + const { factory } = makeFactory( + jest.fn().mockResolvedValue({ questoesNovo: [] }), + ); + expect(await factory.verifyNumberProva('p1', 120)).toBe(true); + }); + + it('idiomático (91-95) com 1 ocorrência → true', async () => { + const { factory } = makeFactory( + jest + .fn() + .mockResolvedValue({ questoesNovo: [{ numero: 92, questao: {} }] }), + ); + expect(await factory.verifyNumberProva('p1', 92)).toBe(true); + }); + + it('idiomático (91-95) com 2 ocorrências → false', async () => { + const { factory } = makeFactory( + jest.fn().mockResolvedValue({ + questoesNovo: [ + { numero: 92, questao: {} }, + { numero: 92, questao: {} }, + ], + }), + ); + expect(await factory.verifyNumberProva('p1', 92)).toBe(false); + }); + + it('não-idiomático ocupado → false', async () => { + const { factory } = makeFactory( + jest + .fn() + .mockResolvedValue({ questoesNovo: [{ numero: 100, questao: {} }] }), + ); + expect(await factory.verifyNumberProva('p1', 100)).toBe(false); + }); +}); diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index c2983d2..c6f89d0 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -91,13 +91,12 @@ export class Enem2010_2017Factory implements IProvaFactory { const missingQuestion = []; if (day2) { for (let index = prova.inicialNumero; index <= 180; index++) { - if (!prova.questoes.find((quest) => quest.numero === index)) { + if (!prova.questoesNovo.find((qc) => qc.numero === index)) { missingQuestion.push(index); } else { if (index > 90 && index < 96) { const hasAllQuestion = - prova.questoes.filter((quest) => quest.numero === index).length < - 2; + prova.questoesNovo.filter((qc) => qc.numero === index).length < 2; if (hasAllQuestion) { missingQuestion.push(index); } @@ -106,7 +105,7 @@ export class Enem2010_2017Factory implements IProvaFactory { } } else { for (let index = prova.inicialNumero; index <= 90; index++) { - if (!prova.questoes.find((quest) => quest.numero === index)) { + if (!prova.questoesNovo.find((qc) => qc.numero === index)) { missingQuestion.push(index); } } @@ -119,10 +118,10 @@ export class Enem2010_2017Factory implements IProvaFactory { numberQuestion: number, ): Promise { const prova = await this.provaRepository.getProvaWithQuestion(id); - if (prova.questoes.some((quest) => quest.numero === numberQuestion)) { + if (prova.questoesNovo.some((qc) => qc.numero === numberQuestion)) { if (numberQuestion > 90 && numberQuestion < 96) { return ( - prova.questoes.filter((quest) => quest.numero === numberQuestion) + prova.questoesNovo.filter((qc) => qc.numero === numberQuestion) .length < 2 ); } From b4e39afe58fb1b61a55fbb999957193a3f14994e Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 17:23:40 -0300 Subject: [PATCH 22/65] feat(prova): CustomProvaFactory getMissingNumbers/verifyNumberProva leem questoesNovo Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/factory/custom_prova_factory.spec.ts | 8 ++++---- src/modules/prova/factory/custom_prova_factory.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/modules/prova/factory/custom_prova_factory.spec.ts b/src/modules/prova/factory/custom_prova_factory.spec.ts index 0f98d2d..4919a99 100644 --- a/src/modules/prova/factory/custom_prova_factory.spec.ts +++ b/src/modules/prova/factory/custom_prova_factory.spec.ts @@ -250,7 +250,7 @@ describe('CustomProvaFactory.verifyNumberProva', () => { const { factory } = makeFactory({ getProvaWithQuestion: jest .fn() - .mockResolvedValue({ questoes: [{ numero: 5 }] }), + .mockResolvedValue({ questoesNovo: [{ numero: 5 }] }), }); expect(await factory.verifyNumberProva('p1', 5)).toBe(false); }); @@ -259,7 +259,7 @@ describe('CustomProvaFactory.verifyNumberProva', () => { const { factory } = makeFactory({ getProvaWithQuestion: jest .fn() - .mockResolvedValue({ questoes: [{ numero: 5 }] }), + .mockResolvedValue({ questoesNovo: [{ numero: 5 }] }), }); expect(await factory.verifyNumberProva('p1', 7)).toBe(true); }); @@ -270,7 +270,7 @@ describe('CustomProvaFactory.getMissingNumbers', () => { const { factory } = makeFactory(); const prova = { categoria: { quantidadeTotalQuestao: null }, - questoes: [], + questoesNovo: [], } as any; expect(await factory.getMissingNumbers(prova)).toEqual([]); }); @@ -279,7 +279,7 @@ describe('CustomProvaFactory.getMissingNumbers', () => { const { factory } = makeFactory(); const prova = { categoria: { quantidadeTotalQuestao: 4 }, - questoes: [{ numero: 2 }, { numero: 4 }], + questoesNovo: [{ 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 index 91965bc..34b36e0 100644 --- a/src/modules/prova/factory/custom_prova_factory.ts +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -73,6 +73,7 @@ export class CustomProvaFactory implements IProvaFactory { descricao: `${prova.categoria.exame.nome}`, categoria: prova.categoria, questoes: [], + questoesNovo: [], criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, } as any); @@ -145,7 +146,7 @@ export class CustomProvaFactory implements IProvaFactory { numberQuestion: number, ): Promise { const prova = await this.provaRepository.getProvaWithQuestion(id); - return !prova.questoes.some((quest) => quest.numero === numberQuestion); + return !prova.questoesNovo.some((qc) => qc.numero === numberQuestion); } public async getMissingNumbers(prova: Prova): Promise { @@ -155,7 +156,7 @@ export class CustomProvaFactory implements IProvaFactory { const missingQuestion: number[] = []; for (let i = 1; i <= prova.categoria.quantidadeTotalQuestao; i++) { - if (!prova.questoes.find((quest) => quest.numero === i)) { + if (!prova.questoesNovo.find((qc) => qc.numero === i)) { missingQuestion.push(i); } } From b54b43a70415b8224ce1dd5c2384f56431b89d1d Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 17:27:12 -0300 Subject: [PATCH 23/65] feat(prova): executeSync grava questoesNovo single-write (selectQuestions inalterado) Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.service.spec.ts | 63 +++++++++++++++++++++++++ src/modules/prova/prova.service.ts | 28 ++++++----- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts index 994da72..838a3f6 100644 --- a/src/modules/prova/prova.service.spec.ts +++ b/src/modules/prova/prova.service.spec.ts @@ -216,3 +216,66 @@ describe('ProvaService.refuseQuestion — recompute questoesNovo', () => { expect(repository.update).toHaveBeenCalledWith(prova); }); }); + +describe('ProvaService.executeSync (single-write questoesNovo)', () => { + it('grava questoesNovo na prova e no simulado (custom) e não escreve questoes', async () => { + const questao: any = { + _id: 'qA', + numero: 1, + status: Status.Approved, + prova: 'p1', + frente1: { _id: 'f1' }, + enemArea: 'Matemática', + }; + const simulado: any = { + _id: 's1', + nome: 'Sim custom', + categoria: { quantidadeTotalQuestao: 1 }, + questoes: [], + questoesNovo: [], + bloqueado: true, + }; + const prova: any = { + _id: 'p1', + nome: 'Prova custom', + ano: 2020, + categoria: { custom: true, quantidadeTotalQuestao: 1, exame: {} }, + simulados: [simulado], + questoes: [], + questoesNovo: [], + }; + + const repository: any = { + getAllPopulated: jest.fn().mockResolvedValue([prova]), + update: jest.fn().mockResolvedValue(undefined), + }; + const simuladoRepository: any = { + update: jest.fn().mockResolvedValue(undefined), + }; + const questaoRepository: any = { + getAllByProvaIds: jest.fn().mockResolvedValue([questao]), + }; + const frenteRepository: any = { + getByFilter: jest.fn().mockResolvedValue({ _id: 'fx' }), + }; + + const service = new ProvaService( + {} as any, // provaFactory + repository, + {} as any, // categoriaRepository + simuladoRepository, + questaoRepository, + frenteRepository, + ); + + await (service as any).executeSync(); + + expect(repository.update).toHaveBeenCalledWith(prova); + expect(prova.questoesNovo).toEqual([{ questao: 'qA', numero: 1 }]); + expect(prova.questoes).toEqual([]); + expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); + expect(simulado.questoesNovo).toEqual([{ questao: 'qA', numero: 1 }]); + expect(simulado.questoes).toEqual([]); + expect(simulado.bloqueado).toBe(false); + }); +}); diff --git a/src/modules/prova/prova.service.ts b/src/modules/prova/prova.service.ts index ea804ae..5313835 100644 --- a/src/modules/prova/prova.service.ts +++ b/src/modules/prova/prova.service.ts @@ -279,9 +279,9 @@ export class ProvaService { // Passo A: Reconstruir prova.questoes a partir das questoes que apontam para esta prova const questoesDaProva = questoesByProva.get(prova._id.toString()) || []; - const oldQuestaoIds = (prova.questoes || []) - .filter((q) => q !== null && q._id !== undefined) - .map((q) => q._id.toString()) + const oldQuestaoIds = (prova.questoesNovo || []) + .filter((qc) => qc != null && qc.questao != null) + .map((qc) => ((qc.questao as any)._id ?? qc.questao).toString()) .sort(); const newQuestaoIds = questoesDaProva .map((q) => q._id.toString()) @@ -298,9 +298,11 @@ export class ProvaService { detail: `Reconstruido array de questoes da prova`, }); } - prova.questoes = questoesDaProva.sort( - (a, b) => a.numero - b.numero, - ) as any; + questoesDaProva.sort((a, b) => a.numero - b.numero); + prova.questoesNovo = questoesDaProva.map((q) => ({ + questao: q._id, + numero: q.numero, + })) as any; // Passo B: Recalcular totalQuestaoValidadas const actualApproved = questoesDaProva.filter( @@ -335,9 +337,9 @@ export class ProvaService { frenteIngles, frenteEspanhol, ); - const oldSimQuestaoIds = (simulado.questoes || []) - .filter((q) => q !== null && q._id !== undefined) - .map((q) => q._id.toString()) + const oldSimQuestaoIds = (simulado.questoesNovo || []) + .filter((qc) => qc != null && qc.questao != null) + .map((qc) => ((qc.questao as any)._id ?? qc.questao).toString()) .sort(); const newSimQuestaoIds = newSimQuestoes .map((q) => q._id.toString()) @@ -381,9 +383,11 @@ export class ProvaService { } // C3: Atualizar simulado com questoes reconstruidas e ordenadas - simulado.questoes = newSimQuestoes.sort( - (a, b) => a.numero - b.numero, - ) as any; + newSimQuestoes.sort((a, b) => a.numero - b.numero); + simulado.questoesNovo = newSimQuestoes.map((q) => ({ + questao: q._id, + numero: q.numero, + })) as any; simulado.bloqueado = shouldBeBlocked; // C4: Sempre persistir simulado para garantir consistencia From b8b80c901a02142f649edaafd4bf511957ec86f9 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 17:32:33 -0300 Subject: [PATCH 24/65] feat(prova): updateQuestion reconcilia numero no subdoc (cutover, 3 factories) Co-Authored-By: Claude Opus 4.8 --- .../factory/custom_prova_factory.spec.ts | 63 +++++++++++++++++++ .../prova/factory/custom_prova_factory.ts | 17 +++++ .../prova/factory/enem_2010_2016_factory.ts | 17 +++++ .../prova/factory/enem_2017_plus_factory.ts | 17 +++++ 4 files changed, 114 insertions(+) diff --git a/src/modules/prova/factory/custom_prova_factory.spec.ts b/src/modules/prova/factory/custom_prova_factory.spec.ts index 4919a99..8ca4841 100644 --- a/src/modules/prova/factory/custom_prova_factory.spec.ts +++ b/src/modules/prova/factory/custom_prova_factory.spec.ts @@ -32,6 +32,7 @@ function makeFactory(overrides?: { getProvaWithQuestion: overrides?.getProvaWithQuestion ?? jest.fn(), addQuestion: jest.fn().mockResolvedValue(undefined), removeQuestion: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), }; const simuladoService = { addQuestionSimulados: jest.fn().mockResolvedValue(undefined), @@ -39,6 +40,7 @@ function makeFactory(overrides?: { }; const simuladoRepository = { create: jest.fn().mockImplementation(async (s) => ({ ...s, _id: 's-new' })), + update: jest.fn().mockResolvedValue(undefined), }; const categoria = { nome: 'Personalizado 30q 60min', @@ -284,3 +286,64 @@ describe('CustomProvaFactory.getMissingNumbers', () => { expect(await factory.getMissingNumbers(prova)).toEqual([1, 3]); }); }); + +describe('CustomProvaFactory.updateQuestion — numero-sync', () => { + it('reconcilia o numero no subdoc da prova e do simulado quando o número muda', async () => { + const simulado: any = { + _id: 's1', + questoesNovo: [{ questao: { _id: 'qX' }, numero: 5 }], + }; + const prova: any = { + _id: 'p1', + simulados: [simulado], + questoesNovo: [{ questao: { _id: 'qX' }, numero: 5 }], + }; + const getById = jest.fn().mockResolvedValue(prova); + const getByIdToUpdate = jest.fn().mockResolvedValue({ + _id: 'qX', + numero: 5, + prova: { _id: 'p1' }, + }); + const { factory, provaRepository, simuladoRepository } = makeFactory({ + getById, + getByIdToUpdate, + }); + + await factory.updateQuestion({ + _id: 'qX', + numero: 6, + prova: 'p1', + } as any); + + expect(prova.questoesNovo[0].numero).toBe(6); + expect(provaRepository.update).toHaveBeenCalledWith(prova); + expect(simulado.questoesNovo[0].numero).toBe(6); + expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); + }); + + it('NÃO reconcilia quando o número não muda', async () => { + const prova: any = { + _id: 'p1', + simulados: [], + questoesNovo: [{ questao: { _id: 'qX' }, numero: 5 }], + }; + const getById = jest.fn().mockResolvedValue(prova); + const getByIdToUpdate = jest.fn().mockResolvedValue({ + _id: 'qX', + numero: 5, + prova: { _id: 'p1' }, + }); + const { factory, provaRepository } = makeFactory({ + getById, + getByIdToUpdate, + }); + + await factory.updateQuestion({ + _id: 'qX', + numero: 5, + prova: 'p1', + } as any); + + expect(provaRepository.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/prova/factory/custom_prova_factory.ts b/src/modules/prova/factory/custom_prova_factory.ts index 34b36e0..242cd9e 100644 --- a/src/modules/prova/factory/custom_prova_factory.ts +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -7,6 +7,7 @@ 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 { updateNumeroNoContainer } from '../helpers/question-container.helpers'; import { ProvaRepository } from '../prova.repository'; import { Prova } from '../prova.schema'; import { IProvaFactory } from './types'; @@ -139,6 +140,22 @@ export class CustomProvaFactory implements IProvaFactory { } finally { session.endSession(); } + + // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place + // na prova de destino e seus simulados que referenciam a questão. + if (question.numero != null && question.numero !== questao.numero) { + const provaAtual = await this.provaRepository.getById(question.prova); + if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { + await this.provaRepository.update(provaAtual); + } + await Promise.all( + provaAtual.simulados.map(async (sml) => { + if (updateNumeroNoContainer(sml, question._id, question.numero)) { + await this.simuladoRepository.update(sml); + } + }), + ); + } } public async verifyNumberProva( diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index c6f89d0..fde31bf 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -11,6 +11,7 @@ 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 { CreateProvaDTOInput } from '../dtos/create.dto.input'; +import { updateNumeroNoContainer } from '../helpers/question-container.helpers'; import { ProvaRepository } from '../prova.repository'; import { Prova } from '../prova.schema'; import { EnemService } from '../services/enem_service'; @@ -307,6 +308,22 @@ export class Enem2010_2017Factory implements IProvaFactory { } finally { session.endSession(); } + + // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place + // na prova de destino e seus simulados que referenciam a questão. + if (question.numero != null && question.numero !== questao.numero) { + const provaAtual = await this.provaRepository.getById(question.prova); + if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { + await this.provaRepository.update(provaAtual); + } + await Promise.all( + provaAtual.simulados.map(async (sml) => { + if (updateNumeroNoContainer(sml, question._id, question.numero)) { + await this.simuladoRepository.update(sml); + } + }), + ); + } } // Busca a frente pelo nome (reaproveitada da função createQuestion) diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index fac1a98..9cd2a6c 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -11,6 +11,7 @@ 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 { CreateProvaDTOInput } from '../dtos/create.dto.input'; +import { updateNumeroNoContainer } from '../helpers/question-container.helpers'; import { ProvaRepository } from '../prova.repository'; import { Prova } from '../prova.schema'; import { EnemService } from '../services/enem_service'; @@ -284,6 +285,22 @@ export class Enem2017PlusFactory implements IProvaFactory { } finally { session.endSession(); } + + // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place + // na prova de destino e seus simulados que referenciam a questão. + if (question.numero != null && question.numero !== questao.numero) { + const provaAtual = await this.provaRepository.getById(question.prova); + if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { + await this.provaRepository.update(provaAtual); + } + await Promise.all( + provaAtual.simulados.map(async (sml) => { + if (updateNumeroNoContainer(sml, question._id, question.numero)) { + await this.simuladoRepository.update(sml); + } + }), + ); + } } // --- Funções auxiliares --- From aa874d37aff613a0ddae4d6f79cf0ffb285ca9f8 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 17:44:15 -0300 Subject: [PATCH 25/65] =?UTF-8?q?test(prova):=20numero-sync=20integra?= =?UTF-8?q?=C3=A7=C3=A3o=20no=20Enem2017PlusFactory.updateQuestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../factory/enem_2017_plus_factory.spec.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts index eccf42f..8c9f3ac 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts @@ -87,3 +87,84 @@ describe('Enem2017PlusFactory.verifyNumberProva (Regra C)', () => { expect(await factory.verifyNumberProva('p1', 10)).toBe(false); }); }); + +describe('Enem2017PlusFactory.updateQuestion — numero-sync (mudança pura de número)', () => { + function makeSession() { + return { + startTransaction: jest.fn(), + commitTransaction: jest.fn().mockResolvedValue(undefined), + abortTransaction: jest.fn().mockResolvedValue(undefined), + endSession: jest.fn(), + }; + } + + it('reconcilia o numero no subdoc da prova e do simulado quando só o número muda', async () => { + const simulado: any = { + _id: 's1', + questoesNovo: [{ questao: { _id: 'qX' }, numero: 10 }], + }; + const prova: any = { + _id: 'p1', + simulados: [simulado], + questoesNovo: [{ questao: { _id: 'qX' }, numero: 10 }], + }; + + const session = makeSession(); + const questaoRepository: any = { + startSession: jest.fn().mockResolvedValue(session), + getByIdToUpdate: jest.fn().mockResolvedValue({ + _id: 'qX', + numero: 10, + enemArea: 'X', + prova: { _id: { toString: () => 'p1' } }, + frente1: { _id: { toString: () => 'f0' } }, + }), + updateQuestion: jest.fn().mockResolvedValue(undefined), + }; + const provaRepository: any = { + getById: jest.fn().mockResolvedValue(prova), + update: jest.fn().mockResolvedValue(undefined), + }; + const frenteRepository: any = { + getByFilter: jest.fn().mockResolvedValue({ _id: { toString: () => 'fi' } }), + }; + const simuladoService: any = { + addQuestionSimulados: jest.fn().mockResolvedValue(undefined), + removeQuestionSimulados: jest.fn().mockResolvedValue(undefined), + }; + const simuladoRepository: any = { + update: jest.fn().mockResolvedValue(undefined), + removeDuplicatedSimulados: jest.fn().mockReturnValue([]), + }; + const enemService: any = { + validate: jest.fn().mockResolvedValue(undefined), + }; + + const factory = new Enem2017PlusFactory( + {} as any, // categoriaRepository + questaoRepository, + provaRepository, + frenteRepository, + simuladoService, + simuladoRepository, + enemService, + ); + + await factory.updateQuestion({ + _id: 'qX', + numero: 11, + prova: 'p1', + enemArea: 'X', + frente1: 'f0', + } as any); + + // nenhuma operação estrutural (mesma prova/área) + expect(simuladoService.addQuestionSimulados).not.toHaveBeenCalled(); + expect(simuladoService.removeQuestionSimulados).not.toHaveBeenCalled(); + // numero-sync reconciliou prova + simulado + expect(prova.questoesNovo[0].numero).toBe(11); + expect(provaRepository.update).toHaveBeenCalledWith(prova); + expect(simulado.questoesNovo[0].numero).toBe(11); + expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); + }); +}); From 4a0eb619003c55b71751757dfb634cbfdc4e04b9 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 18:20:04 -0300 Subject: [PATCH 26/65] feat(simulado): GetSimuladoById monta de questoesNovo + getById sem populate de questoes Co-Authored-By: Claude Opus 4.8 --- .../simulado/simulado.repository.spec.ts | 26 +++++++++ src/modules/simulado/simulado.repository.ts | 4 -- src/modules/simulado/simulado.service.spec.ts | 53 ++++++++++++++++++- src/modules/simulado/simulado.service.ts | 20 +++---- 4 files changed, 88 insertions(+), 15 deletions(-) diff --git a/src/modules/simulado/simulado.repository.spec.ts b/src/modules/simulado/simulado.repository.spec.ts index 9e68966..cac2082 100644 --- a/src/modules/simulado/simulado.repository.spec.ts +++ b/src/modules/simulado/simulado.repository.spec.ts @@ -108,3 +108,29 @@ describe('SimuladoRepository.answer (popula questoesNovo.questao)', () => { }); }); }); + +describe('SimuladoRepository.getById (popula questoesNovo.questao, sem questoes)', () => { + it('popula categoria + questoesNovo.questao e NÃO popula questoes', async () => { + const populateArgs: any[] = []; + const exec = jest.fn().mockResolvedValue({ _id: 's1' }); + const query: any = { exec }; + query.populate = jest.fn((arg: any) => { + populateArgs.push(arg); + return query; + }); + const findById = jest.fn().mockReturnValue(query); + const repo = new SimuladoRepository({ findById } as any); + + await repo.getById('s1'); + + expect(populateArgs).toContainEqual({ + path: 'questoesNovo.questao', + populate: ['frente1', 'materia'], + }); + expect( + populateArgs.some( + (a) => a && typeof a === 'object' && a.path === 'questoes', + ), + ).toBe(false); + }); +}); diff --git a/src/modules/simulado/simulado.repository.ts b/src/modules/simulado/simulado.repository.ts index 52148f9..f9beb0e 100644 --- a/src/modules/simulado/simulado.repository.ts +++ b/src/modules/simulado/simulado.repository.ts @@ -23,10 +23,6 @@ export class SimuladoRepository extends BaseRepository { return await this.model .findById(id) .populate('categoria') - .populate({ - path: 'questoes', - populate: ['frente1', 'materia'], - }) .populate({ path: 'questoesNovo.questao', populate: ['frente1', 'materia'], diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index 55d3f29..c60dfb2 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -92,7 +92,7 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { disponivelDe: null, disponivelAte: null, categoria: { _id: 'c1', duracao: 60 }, - questoes: [], + questoesNovo: [], } as any; const service = makeService({ getAvailabilityById: jest.fn().mockResolvedValue({ @@ -105,6 +105,57 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { const result = await service.getToAnswer('s1'); expect(result).toMatchObject({ _id: 's1', nome: 'S', duracao: 60 }); }); + + it('monta questoes a partir de questoesNovo (numero do subdoc)', async () => { + const simulado = { + _id: 's1', + nome: 'S', + descricao: 'd', + bloqueado: false, + disponivelDe: null, + disponivelAte: null, + categoria: { _id: 'c1', duracao: 60 }, + questoesNovo: [ + { + questao: { + _id: 'q1', + enemArea: 'Mat', + frente1: { _id: 'f1' }, + frente2: null, + frente3: null, + materia: { _id: 'm1' }, + imageId: 'img1', + prova: 'p1', + }, + numero: 7, + }, + ], + } 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'); + + expect(result!.questoes).toEqual([ + { + _id: 'q1', + enemArea: 'Mat', + frente1: { _id: 'f1' }, + frente2: null, + frente3: null, + materia: { _id: 'm1' }, + numero: 7, + imageId: 'img1', + prova: 'p1', + }, + ]); + }); }); describe('SimuladoService.updateDisponibilidade', () => { diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 2681895..451cea7 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -271,16 +271,16 @@ export class SimuladoService { nome: simulado.nome, descricao: simulado.descricao, categoria: simulado.categoria._id, - questoes: simulado.questoes.map((q) => ({ - _id: q._id, - enemArea: q.enemArea, - frente1: q.frente1, - frente2: q.frente2, - frente3: q.frente3, - materia: q.materia, - numero: q.numero, - imageId: q.imageId, - prova: q.prova, + questoes: simulado.questoesNovo.map((qc) => ({ + _id: qc.questao._id, + enemArea: qc.questao.enemArea, + frente1: qc.questao.frente1, + frente2: qc.questao.frente2, + frente3: qc.questao.frente3, + materia: qc.questao.materia, + numero: qc.numero, + imageId: qc.questao.imageId, + prova: qc.questao.prova, })), inicio: inicio, duracao: simulado.categoria.duracao, From c6af73befa719da73fb945dbe18fad243e058596 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 18:23:26 -0300 Subject: [PATCH 27/65] refactor(prova): getById sem populate de questoes (cutover, so questoesNovo.questao) Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.repository.spec.ts | 5 ++--- src/modules/prova/prova.repository.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts index 5c95ddb..822d8dc 100644 --- a/src/modules/prova/prova.repository.spec.ts +++ b/src/modules/prova/prova.repository.spec.ts @@ -115,14 +115,13 @@ describe('ProvaRepository.getById (popula questoesNovo.questao)', () => { expect(findById).toHaveBeenCalledWith('p1'); // populate top-level de questoesNovo.questao expect(populateCalls).toContain('questoesNovo.questao'); + expect(populateCalls).not.toContain('questoes'); // populate aninhado nos simulados inclui questoesNovo.questao const nested = populateCalls.find( (c) => c && typeof c === 'object' && c.path === 'simulados', ); expect(nested).toBeDefined(); - expect(nested.populate).toEqual( - expect.arrayContaining([{ path: 'questoesNovo.questao' }]), - ); + expect(nested.populate).toEqual(['categoria', { path: 'questoesNovo.questao' }]); }); }); diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index 6134a29..5079aad 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -32,12 +32,12 @@ export class ProvaRepository extends BaseRepository { async getById(id: string): Promise { return await this.model .findById(id) - .populate(['simulados', 'questoes']) + .populate('simulados') .populate('questoesNovo.questao') .populate({ path: 'categoria', populate: 'exame' }) .populate({ path: 'simulados', - populate: ['categoria', 'questoes', { path: 'questoesNovo.questao' }], + populate: ['categoria', { path: 'questoesNovo.questao' }], }); } From bcf6187b5fcd091b9cf9c3ee147f04b8c7f7da80 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 18:26:22 -0300 Subject: [PATCH 28/65] feat(historico): getById popula simulado.questoesNovo.questao (cutover) Co-Authored-By: Claude Opus 4.8 --- .../historico/historico.repository.spec.ts | 18 ++++++++++++++++++ src/modules/historico/historico.repository.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 src/modules/historico/historico.repository.spec.ts diff --git a/src/modules/historico/historico.repository.spec.ts b/src/modules/historico/historico.repository.spec.ts new file mode 100644 index 0000000..7bd99e2 --- /dev/null +++ b/src/modules/historico/historico.repository.spec.ts @@ -0,0 +1,18 @@ +import { HistoricoRepository } from './historico.repository'; + +describe('HistoricoRepository.getById (popula simulado.questoesNovo.questao)', () => { + it('popula simulado com tipo + questoesNovo.questao (sem questoes)', async () => { + const exec = jest.fn().mockResolvedValue({ _id: 'h1' }); + const populate = jest.fn().mockReturnValue({ exec }); + const findById = jest.fn().mockReturnValue({ populate }); + const repo = new HistoricoRepository({ findById } as any); + + await repo.getById('h1'); + + expect(findById).toHaveBeenCalledWith('h1'); + expect(populate).toHaveBeenCalledWith({ + path: 'simulado', + populate: ['tipo', { path: 'questoesNovo.questao' }], + }); + }); +}); diff --git a/src/modules/historico/historico.repository.ts b/src/modules/historico/historico.repository.ts index 7fa33e0..34cb074 100644 --- a/src/modules/historico/historico.repository.ts +++ b/src/modules/historico/historico.repository.ts @@ -51,7 +51,7 @@ export class HistoricoRepository extends BaseRepository { .findById(id) .populate({ path: 'simulado', - populate: ['tipo', 'questoes'], + populate: ['tipo', { path: 'questoesNovo.questao' }], }) .exec(); } From 9b5220ca588369e2519c87209a750fa141fd7737 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 18:29:31 -0300 Subject: [PATCH 29/65] feat(historico): getById remapeia questoesNovo.questao para simulado.questoes (client intocado) Co-Authored-By: Claude Opus 4.8 --- .../historico/historico.service.spec.ts | 34 +++++++++++++++++++ src/modules/historico/historico.service.ts | 13 ++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/modules/historico/historico.service.spec.ts b/src/modules/historico/historico.service.spec.ts index b499989..3eff2d2 100644 --- a/src/modules/historico/historico.service.spec.ts +++ b/src/modules/historico/historico.service.spec.ts @@ -70,3 +70,37 @@ describe('HistoricoService.calcularMediaAproveitamento', () => { expect(result.historicos[0].totalQuestionsTest).toBe(2); }); }); + +describe('HistoricoService.getById (remap questoesNovo → simulado.questoes)', () => { + it('achata questoesNovo.questao em simulado.questoes e remove questoesNovo', async () => { + const historico = { + toObject: () => ({ + _id: 'h1', + simulado: { + _id: 's1', + nome: 'S', + questoesNovo: [ + { questao: { _id: 'q1', textoQuestao: 'a', numero: 5, prova: 'p1' }, numero: 1 }, + { questao: { _id: 'q2', textoQuestao: 'b', numero: 6, prova: 'p1' }, numero: 2 }, + ], + }, + }), + }; + const repository: any = { getById: jest.fn().mockResolvedValue(historico) }; + const service = new HistoricoService(repository); + + const result: any = await service.getById('h1'); + + expect(result.simulado.questoes).toEqual([ + { _id: 'q1', textoQuestao: 'a', numero: 5, prova: 'p1' }, + { _id: 'q2', textoQuestao: 'b', numero: 6, prova: 'p1' }, + ]); + expect(result.simulado.questoesNovo).toBeUndefined(); + }); + + it('retorna null quando não encontra', async () => { + const repository: any = { getById: jest.fn().mockResolvedValue(null) }; + const service = new HistoricoService(repository); + expect(await service.getById('x')).toBeNull(); + }); +}); diff --git a/src/modules/historico/historico.service.ts b/src/modules/historico/historico.service.ts index bd610d6..30bba44 100644 --- a/src/modules/historico/historico.service.ts +++ b/src/modules/historico/historico.service.ts @@ -18,7 +18,18 @@ export class HistoricoService { } async getById(id: string) { - return await this.repository.getById(id); + const historico = await this.repository.getById(id); + if (!historico) return historico; + const obj: any = (historico as any).toObject + ? (historico as any).toObject() + : historico; + if (obj.simulado && Array.isArray(obj.simulado.questoesNovo)) { + obj.simulado.questoes = obj.simulado.questoesNovo.map( + (qc: any) => qc.questao, + ); + delete obj.simulado.questoesNovo; + } + return obj; } async getPerformance(userId: string): Promise { From bee6ea1618019e2731b21d649d1ada0089a68b9d Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 19:31:26 -0300 Subject: [PATCH 30/65] refactor(prova): remove executeSync + endpoints /prova/sync (obsoleto no cutover) Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/dtos/sync-report.dto.ts | 36 --- src/modules/prova/prova.controller.ts | 25 -- src/modules/prova/prova.repository.spec.ts | 23 -- src/modules/prova/prova.repository.ts | 11 - src/modules/prova/prova.service.spec.ts | 111 -------- src/modules/prova/prova.service.ts | 290 --------------------- src/modules/questao/questao.repository.ts | 7 - 7 files changed, 503 deletions(-) delete mode 100644 src/modules/prova/dtos/sync-report.dto.ts diff --git a/src/modules/prova/dtos/sync-report.dto.ts b/src/modules/prova/dtos/sync-report.dto.ts deleted file mode 100644 index dc94055..0000000 --- a/src/modules/prova/dtos/sync-report.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface FixDetail { - field: string; - issue: string; - oldValue: any; - newValue: any; - detail?: string; -} - -export interface ProvaFixEntry { - provaId: string; - provaNome: string; - fixes: FixDetail[]; -} - -export interface SimuladoFixEntry { - simuladoId: string; - simuladoNome: string; - fixes: FixDetail[]; -} - -export interface SyncError { - provaId: string; - provaNome: string; - error: string; -} - -export type SyncStatus = 'idle' | 'processing' | 'completed' | 'error'; - -export interface SyncReport { - status: SyncStatus; - processedAt: string | null; - totalProvas: number; - provasFixed: ProvaFixEntry[]; - simuladosFixed: SimuladoFixEntry[]; - errors: SyncError[]; -} diff --git a/src/modules/prova/prova.controller.ts b/src/modules/prova/prova.controller.ts index 2420fc6..9c3f9c9 100644 --- a/src/modules/prova/prova.controller.ts +++ b/src/modules/prova/prova.controller.ts @@ -2,8 +2,6 @@ import { Body, Controller, Get, - HttpCode, - HttpStatus, Param, Patch, Post, @@ -68,29 +66,6 @@ export class ProvaController { return await this.service.getSummary(); } - @Post('sync') - @HttpCode(HttpStatus.ACCEPTED) - @ApiResponse({ - status: 202, - description: 'Inicia sincronizacao em background', - }) - @ApiResponse({ - status: 409, - description: 'Sincronizacao ja em andamento', - }) - public startSync() { - return this.service.startSync(); - } - - @Get('sync/report') - @ApiResponse({ - status: 200, - description: 'Retorna o relatorio da ultima sincronizacao', - }) - public getSyncReport() { - return this.service.getSyncReport(); - } - @Get(':id') @ApiResponse({ status: 200, diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts index 822d8dc..21fd5e3 100644 --- a/src/modules/prova/prova.repository.spec.ts +++ b/src/modules/prova/prova.repository.spec.ts @@ -148,26 +148,3 @@ describe('ProvaRepository.getProvaWithQuestion (sem populate de questoes)', () = ).toBe(true); }); }); - -describe('ProvaRepository.getAllPopulated (sem populate de questoes)', () => { - it('popula categoria e simulados.categoria, NÃO popula questoes', async () => { - const populateArgs: any[] = []; - const exec = jest.fn().mockResolvedValue([]); - const query: any = { exec }; - query.find = jest.fn().mockReturnValue(query); - query.populate = jest.fn((arg: any) => { - populateArgs.push(arg); - return query; - }); - const repo = new ProvaRepository({ find: query.find } as any); - - await repo.getAllPopulated(); - - expect(populateArgs).not.toContain('questoes'); - const simuladosPop = populateArgs.find( - (a) => a && typeof a === 'object' && a.path === 'simulados', - ); - expect(simuladosPop).toBeDefined(); - expect(simuladosPop.populate).toEqual(['categoria']); - }); -}); diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index 5079aad..c32b0fa 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -79,17 +79,6 @@ export class ProvaRepository extends BaseRepository { return { data, page, limit, totalItems }; } - async getAllPopulated(): Promise { - return await this.model - .find() - .populate({ path: 'categoria', populate: 'exame' }) - .populate({ - path: 'simulados', - populate: ['categoria'], - }) - .exec(); - } - async getTotalEntity() { return this.model.find({ deletedAt: null }).count(); } diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts index 838a3f6..6e11d57 100644 --- a/src/modules/prova/prova.service.spec.ts +++ b/src/modules/prova/prova.service.spec.ts @@ -13,7 +13,6 @@ function makeService(overrides?: { getById?: jest.Mock }) { {} as any, // categoriaRepository simuladoRepository as any, {} as any, // questaoRepository - {} as any, // frenteRepository ); return { service, repository, simuladoRepository }; } @@ -61,52 +60,6 @@ describe('ProvaService.approvedQuestion — regra bloqueado com qtd null', () => }); }); -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); - }); -}); - describe('ProvaService.getAllByCursinho', () => { function makeProva(id: string) { return { @@ -134,7 +87,6 @@ describe('ProvaService.getAllByCursinho', () => { {} as any, // categoriaRepository {} as any, // simuladoRepository {} as any, // questaoRepository - {} as any, // frenteRepository ); return { service, repository }; } @@ -216,66 +168,3 @@ describe('ProvaService.refuseQuestion — recompute questoesNovo', () => { expect(repository.update).toHaveBeenCalledWith(prova); }); }); - -describe('ProvaService.executeSync (single-write questoesNovo)', () => { - it('grava questoesNovo na prova e no simulado (custom) e não escreve questoes', async () => { - const questao: any = { - _id: 'qA', - numero: 1, - status: Status.Approved, - prova: 'p1', - frente1: { _id: 'f1' }, - enemArea: 'Matemática', - }; - const simulado: any = { - _id: 's1', - nome: 'Sim custom', - categoria: { quantidadeTotalQuestao: 1 }, - questoes: [], - questoesNovo: [], - bloqueado: true, - }; - const prova: any = { - _id: 'p1', - nome: 'Prova custom', - ano: 2020, - categoria: { custom: true, quantidadeTotalQuestao: 1, exame: {} }, - simulados: [simulado], - questoes: [], - questoesNovo: [], - }; - - const repository: any = { - getAllPopulated: jest.fn().mockResolvedValue([prova]), - update: jest.fn().mockResolvedValue(undefined), - }; - const simuladoRepository: any = { - update: jest.fn().mockResolvedValue(undefined), - }; - const questaoRepository: any = { - getAllByProvaIds: jest.fn().mockResolvedValue([questao]), - }; - const frenteRepository: any = { - getByFilter: jest.fn().mockResolvedValue({ _id: 'fx' }), - }; - - const service = new ProvaService( - {} as any, // provaFactory - repository, - {} as any, // categoriaRepository - simuladoRepository, - questaoRepository, - frenteRepository, - ); - - await (service as any).executeSync(); - - expect(repository.update).toHaveBeenCalledWith(prova); - expect(prova.questoesNovo).toEqual([{ questao: 'qA', numero: 1 }]); - expect(prova.questoes).toEqual([]); - expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); - expect(simulado.questoesNovo).toEqual([{ questao: 'qA', numero: 1 }]); - expect(simulado.questoes).toEqual([]); - expect(simulado.bloqueado).toBe(false); - }); -}); diff --git a/src/modules/prova/prova.service.ts b/src/modules/prova/prova.service.ts index 5313835..37480ad 100644 --- a/src/modules/prova/prova.service.ts +++ b/src/modules/prova/prova.service.ts @@ -2,22 +2,12 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { GetAllInput } from 'src/shared/base/interfaces/get-all.input'; import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; import { CategoriaRepository } from '../categoria/categoria.repository'; -import { FrenteRepository } from '../frente/frente.repository'; -import { Frente } from '../frente/frente.schema'; import { EnemArea } from '../questao/enums/enem-area.enum'; import { Status } from '../questao/enums/status.enum'; import { QuestaoRepository } from '../questao/questao.repository'; -import { Questao } from '../questao/questao.schema'; -import { Simulado } from '../simulado/schemas/simulado.schema'; import { SimuladoRepository } from '../simulado/simulado.repository'; import { CreateProvaDTOInput } from './dtos/create.dto.input'; import { GetProvaDTOOutout } from './dtos/get-all.dto.output'; -import { - ProvaFixEntry, - SimuladoFixEntry, - SyncReport, - SyncStatus, -} from './dtos/sync-report.dto'; import { ProvaFactory } from './factory/prova_factory'; import { ProvaRepository } from './prova.repository'; import { Prova } from './prova.schema'; @@ -26,16 +16,12 @@ import { atingiuQuantidade } from '../simulado/helpers/bloqueado'; @Injectable() export class ProvaService { - private syncReport: SyncReport | null = null; - private isSyncing = false; - constructor( private readonly provaFactory: ProvaFactory, private readonly repository: ProvaRepository, private readonly categoriaRepository: CategoriaRepository, private readonly simuladoRepository: SimuladoRepository, private readonly questaoRepository: QuestaoRepository, - private readonly frenteRepository: FrenteRepository, ) {} public async create(item: CreateProvaDTOInput): Promise { @@ -209,282 +195,6 @@ export class ProvaService { } } - public startSync(): { status: SyncStatus; message: string } { - if (this.isSyncing) { - throw new HttpException( - 'Sincronizacao ja em andamento', - HttpStatus.CONFLICT, - ); - } - - this.isSyncing = true; - this.executeSync(); - - return { status: 'processing', message: 'Sincronizacao iniciada' }; - } - - public getSyncReport(): SyncReport | { status: SyncStatus } { - if (this.isSyncing) { - return { status: 'processing' }; - } - if (!this.syncReport) { - return { status: 'idle' }; - } - return this.syncReport; - } - - private async executeSync(): Promise { - const report: SyncReport = { - status: 'processing', - processedAt: null, - totalProvas: 0, - provasFixed: [], - simuladosFixed: [], - errors: [], - }; - - try { - // Busca frentes de idioma uma vez para toda a sync - const [frenteIngles, frenteEspanhol] = await Promise.all([ - this.frenteRepository.getByFilter({ nome: 'Inglês' }), - this.frenteRepository.getByFilter({ nome: 'Espanhol' }), - ]); - - const provas = await this.repository.getAllPopulated(); - report.totalProvas = provas.length; - - // Busca TODAS as questoes que referenciam essas provas - const provaIds = provas.map((p) => p._id.toString()); - const allQuestoes = - await this.questaoRepository.getAllByProvaIds(provaIds); - - // Agrupa questoes por prova - const questoesByProva = new Map(); - for (const q of allQuestoes) { - const provaId = q.prova?.toString() || ''; - if (!questoesByProva.has(provaId)) { - questoesByProva.set(provaId, []); - } - questoesByProva.get(provaId).push(q); - } - - for (const prova of provas) { - try { - const provaFixes: ProvaFixEntry = { - provaId: prova._id, - provaNome: prova.nome, - fixes: [], - }; - - // Passo A: Reconstruir prova.questoes a partir das questoes que apontam para esta prova - const questoesDaProva = - questoesByProva.get(prova._id.toString()) || []; - const oldQuestaoIds = (prova.questoesNovo || []) - .filter((qc) => qc != null && qc.questao != null) - .map((qc) => ((qc.questao as any)._id ?? qc.questao).toString()) - .sort(); - const newQuestaoIds = questoesDaProva - .map((q) => q._id.toString()) - .sort(); - const questoesChanged = - JSON.stringify(oldQuestaoIds) !== JSON.stringify(newQuestaoIds); - - if (questoesChanged) { - provaFixes.fixes.push({ - field: 'questoes', - issue: 'incorrect_count', - oldValue: oldQuestaoIds.length, - newValue: newQuestaoIds.length, - detail: `Reconstruido array de questoes da prova`, - }); - } - questoesDaProva.sort((a, b) => a.numero - b.numero); - prova.questoesNovo = questoesDaProva.map((q) => ({ - questao: q._id, - numero: q.numero, - })) as any; - - // Passo B: Recalcular totalQuestaoValidadas - const actualApproved = questoesDaProva.filter( - (q) => q.status === Status.Approved, - ).length; - if (prova.totalQuestaoValidadas !== actualApproved) { - provaFixes.fixes.push({ - field: 'totalQuestaoValidadas', - issue: 'incorrect_count', - oldValue: prova.totalQuestaoValidadas, - newValue: actualApproved, - }); - prova.totalQuestaoValidadas = actualApproved; - } - - // Passo C: Reconstruir questoes de cada simulado - const validSimulados = (prova.simulados || []).filter( - (s) => s !== null && s._id !== undefined, - ); - for (const simulado of validSimulados) { - const simuladoFixes: SimuladoFixEntry = { - simuladoId: simulado._id, - simuladoNome: simulado.nome, - fixes: [], - }; - - // C1: Reconstruir simulado.questoes baseado na logica de selecao - const newSimQuestoes = this.selectQuestionsForSimulado( - simulado, - questoesDaProva, - prova, - frenteIngles, - frenteEspanhol, - ); - const oldSimQuestaoIds = (simulado.questoesNovo || []) - .filter((qc) => qc != null && qc.questao != null) - .map((qc) => ((qc.questao as any)._id ?? qc.questao).toString()) - .sort(); - const newSimQuestaoIds = newSimQuestoes - .map((q) => q._id.toString()) - .sort(); - const simQuestoesChanged = - JSON.stringify(oldSimQuestaoIds) !== - JSON.stringify(newSimQuestaoIds); - - if (simQuestoesChanged) { - simuladoFixes.fixes.push({ - field: 'questoes', - issue: 'incorrect_count', - oldValue: oldSimQuestaoIds.length, - newValue: newSimQuestaoIds.length, - detail: `Reconstruido array de questoes do simulado`, - }); - } - - // C2: Recalcular bloqueado - const hasRequiredCount = simulado.categoria - ? atingiuQuantidade( - simulado.categoria.quantidadeTotalQuestao, - newSimQuestoes.length, - ) - : false; - const allApproved = - newSimQuestoes.length > 0 && - newSimQuestoes.every((q) => q.status === Status.Approved); - const shouldBeBlocked = !(hasRequiredCount && allApproved); - - if (simulado.bloqueado !== shouldBeBlocked) { - simuladoFixes.fixes.push({ - field: 'bloqueado', - issue: 'incorrect_value', - oldValue: simulado.bloqueado, - newValue: shouldBeBlocked, - detail: !simulado.categoria - ? 'categoria e nula, definido bloqueado=true' - : `questoes: ${newSimQuestoes.length}/${simulado.categoria.quantidadeTotalQuestao}, todasAprovadas: ${allApproved}`, - }); - } - - // C3: Atualizar simulado com questoes reconstruidas e ordenadas - newSimQuestoes.sort((a, b) => a.numero - b.numero); - simulado.questoesNovo = newSimQuestoes.map((q) => ({ - questao: q._id, - numero: q.numero, - })) as any; - simulado.bloqueado = shouldBeBlocked; - - // C4: Sempre persistir simulado para garantir consistencia - await this.simuladoRepository.update(simulado); - if (simuladoFixes.fixes.length > 0) { - report.simuladosFixed.push(simuladoFixes); - } - } - - // Passo D: Sempre persistir prova para garantir consistencia - await this.repository.update(prova); - if (provaFixes.fixes.length > 0) { - report.provasFixed.push(provaFixes); - } - } catch (error: any) { - report.errors.push({ - provaId: prova._id, - provaNome: prova.nome, - error: error.message, - }); - } - } - - report.status = 'completed'; - } catch (error: any) { - report.status = 'error'; - report.errors.push({ - provaId: '', - provaNome: '', - error: `Erro geral: ${error.message}`, - }); - } finally { - report.processedAt = new Date().toISOString(); - this.syncReport = report; - this.isSyncing = false; - } - } - - /** - * Determina quais questoes da prova devem pertencer a um simulado - * baseado no nome do simulado e nas propriedades da questao (enemArea, frente1). - * Replica a logica das factories selectSimuladosToEnter. - */ - private selectQuestionsForSimulado( - simulado: Simulado, - questoesDaProva: Questao[], - prova: Prova, - 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}` - : ''; - - return questoesDaProva.filter((q) => { - const frente1Id = q.frente1?._id?.toString() || ''; - const isIngles = frente1Id === frenteIngles?._id?.toString(); - const isEspanhol = frente1Id === frenteEspanhol?._id?.toString(); - - // Simulado padrao (ex: "Enem Dia 2 2023"): todas as questoes da prova - if (nome === tipoNomeAno) return true; - - // Simulado completo Ingles (ex: "Enem Dia 1 2023 Inglês"): - // Todas menos as de Espanhol - if (nome === `${tipoNomeAno} Inglês`) { - return !isEspanhol; - } - - // Simulado completo Espanhol: todas menos as de Ingles - if (nome === `${tipoNomeAno} Espanhol`) { - return !isIngles; - } - - // Simulado de area com idioma (ex: "... Linguagens Inglês") - if (nome.includes(q.enemArea)) { - if (nome.includes('Inglês')) { - // Questoes da area que sao Ingles OU que nao sao de idioma especifico - return isIngles || (!isIngles && !isEspanhol); - } - if (nome.includes('Espanhol')) { - return isEspanhol || (!isIngles && !isEspanhol); - } - // Simulado de area sem idioma (ex: "... Ciências Humanas") - return true; - } - - return false; - }); - } - async getSummary() { const provaTotal = await this.repository.getTotalEntity(); return { diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index b58162c..73e4cbc 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -231,13 +231,6 @@ export class QuestaoRepository extends BaseRepository { return !questaoExistente; // se já existe, não pode cadastrar → false } - async getAllByProvaIds(provaIds: string[]): Promise { - return await this.model - .find({ prova: { $in: provaIds }, deletedAt: null }) - .populate(['frente1']) - .exec(); - } - async getTotalEntity() { return this.model.find({ deletedAt: null }).count(); } From 40685423be576044bddfa9867505257876ae993b Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 19:36:27 -0300 Subject: [PATCH 31/65] refactor: remove ultimos usos do array antigo questoes (populate/fixtures) Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.repository.spec.ts | 5 ----- src/modules/prova/prova.repository.ts | 1 - src/modules/simulado/simulado.service.spec.ts | 2 -- 3 files changed, 8 deletions(-) diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts index 21fd5e3..010e7cc 100644 --- a/src/modules/prova/prova.repository.spec.ts +++ b/src/modules/prova/prova.repository.spec.ts @@ -6,7 +6,6 @@ describe('ProvaRepository.addQuestion (single-write questoesNovo)', () => { it('empurra a questão em questoesNovo e incrementa totalQuestaoValidadas quando aprovada', async () => { const prova: any = { _id: 'p1', - questoes: [], questoesNovo: [], totalQuestaoValidadas: 0, }; @@ -23,7 +22,6 @@ describe('ProvaRepository.addQuestion (single-write questoesNovo)', () => { expect(prova.questoesNovo).toHaveLength(1); expect(prova.questoesNovo[0].numero).toBe(3); - expect(prova.questoes).toHaveLength(0); // array antigo congelado expect(prova.totalQuestaoValidadas).toBe(1); expect(updateOne).toHaveBeenCalledWith({ _id: 'p1' }, prova); }); @@ -31,7 +29,6 @@ describe('ProvaRepository.addQuestion (single-write questoesNovo)', () => { it('não incrementa totalQuestaoValidadas quando a questão não está aprovada', async () => { const prova: any = { _id: 'p1', - questoes: [], questoesNovo: [], totalQuestaoValidadas: 0, }; @@ -56,7 +53,6 @@ describe('ProvaRepository.removeQuestion (single-write questoesNovo)', () => { const alvo = new Types.ObjectId(); const prova: any = { _id: 'p1', - questoes: [], questoesNovo: [{ questao: { _id: alvo }, numero: 1 }], totalQuestaoValidadas: 1, }; @@ -77,7 +73,6 @@ describe('ProvaRepository.removeQuestion (single-write questoesNovo)', () => { it('não decrementa quando a questão não estava no container', async () => { const prova: any = { _id: 'p1', - questoes: [], questoesNovo: [{ questao: { _id: new Types.ObjectId() }, numero: 1 }], totalQuestaoValidadas: 1, }; diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index c32b0fa..b3fc535 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -71,7 +71,6 @@ export class ProvaRepository extends BaseRepository { const data = await this.model .find() .populate({ path: 'categoria', populate: { path: 'exame' } }) - .populate('questoes') .skip((page - 1) * limit) .limit(limit ?? Infinity) .where({ ...where }); diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index c60dfb2..c2e3a1e 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -226,7 +226,6 @@ describe('SimuladoService.addQuestionSimulados (single-write questoesNovo)', () const service = makeService({ updateSession }); const sml: any = { _id: 's1', - questoes: [], questoesNovo: [], categoria: { quantidadeTotalQuestao: 1 }, bloqueado: true, @@ -240,7 +239,6 @@ describe('SimuladoService.addQuestionSimulados (single-write questoesNovo)', () await service.addQuestionSimulados([sml], question); expect(sml.questoesNovo).toHaveLength(1); - expect(sml.questoes).toHaveLength(0); // congelado expect(sml.bloqueado).toBe(false); expect(updateSession).toHaveBeenCalledWith(sml, undefined); }); From 1c49595c9c4ae73b31e65da3189f67493c11d35b Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 19:42:49 -0300 Subject: [PATCH 32/65] refactor: rename questoesNovo -> questoes (subdoc vira o array oficial) Co-Authored-By: Claude Opus 4.8 --- .../historico/historico.repository.spec.ts | 6 +-- src/modules/historico/historico.repository.ts | 2 +- .../historico/historico.service.spec.ts | 9 ++-- src/modules/historico/historico.service.ts | 7 ++- .../factory/custom_prova_factory.spec.ts | 18 ++++---- .../prova/factory/custom_prova_factory.ts | 5 +-- .../factory/enem_2010_2016_factory.spec.ts | 16 +++---- .../prova/factory/enem_2010_2016_factory.ts | 11 +++-- .../factory/enem_2017_plus_factory.spec.ts | 32 +++++++------- .../prova/factory/enem_2017_plus_factory.ts | 11 +++-- .../question-container.helpers.spec.ts | 44 +++++++++---------- .../helpers/question-container.helpers.ts | 10 ++--- src/modules/prova/prova.repository.spec.ts | 36 +++++++-------- src/modules/prova/prova.repository.ts | 8 ++-- src/modules/prova/prova.schema.ts | 10 +---- src/modules/prova/prova.service.spec.ts | 16 +++---- src/modules/prova/prova.service.ts | 24 +++++----- src/modules/prova/services/enem_service.ts | 2 - .../simulado/schemas/simulado.schema.ts | 13 +----- .../simulado/simulado.repository.spec.ts | 14 +++--- src/modules/simulado/simulado.repository.ts | 4 +- src/modules/simulado/simulado.service.spec.ts | 38 ++++++++-------- src/modules/simulado/simulado.service.ts | 16 +++---- 23 files changed, 164 insertions(+), 188 deletions(-) diff --git a/src/modules/historico/historico.repository.spec.ts b/src/modules/historico/historico.repository.spec.ts index 7bd99e2..0e6527e 100644 --- a/src/modules/historico/historico.repository.spec.ts +++ b/src/modules/historico/historico.repository.spec.ts @@ -1,7 +1,7 @@ import { HistoricoRepository } from './historico.repository'; -describe('HistoricoRepository.getById (popula simulado.questoesNovo.questao)', () => { - it('popula simulado com tipo + questoesNovo.questao (sem questoes)', async () => { +describe('HistoricoRepository.getById (popula simulado.questoes.questao)', () => { + it('popula simulado com tipo + questoes.questao (sem questoes)', async () => { const exec = jest.fn().mockResolvedValue({ _id: 'h1' }); const populate = jest.fn().mockReturnValue({ exec }); const findById = jest.fn().mockReturnValue({ populate }); @@ -12,7 +12,7 @@ describe('HistoricoRepository.getById (popula simulado.questoesNovo.questao)', ( expect(findById).toHaveBeenCalledWith('h1'); expect(populate).toHaveBeenCalledWith({ path: 'simulado', - populate: ['tipo', { path: 'questoesNovo.questao' }], + populate: ['tipo', { path: 'questoes.questao' }], }); }); }); diff --git a/src/modules/historico/historico.repository.ts b/src/modules/historico/historico.repository.ts index 34cb074..e0ee760 100644 --- a/src/modules/historico/historico.repository.ts +++ b/src/modules/historico/historico.repository.ts @@ -51,7 +51,7 @@ export class HistoricoRepository extends BaseRepository { .findById(id) .populate({ path: 'simulado', - populate: ['tipo', { path: 'questoesNovo.questao' }], + populate: ['tipo', { path: 'questoes.questao' }], }) .exec(); } diff --git a/src/modules/historico/historico.service.spec.ts b/src/modules/historico/historico.service.spec.ts index 3eff2d2..6fffd8c 100644 --- a/src/modules/historico/historico.service.spec.ts +++ b/src/modules/historico/historico.service.spec.ts @@ -30,7 +30,7 @@ function makeHistorico(aproveitamento: AproveitamentoHistorico) { _id: 'hist-id', simulado: { nome: 'Simulado', - questoesNovo: [{}, {}] as any[], + questoes: [{}, {}] as any[], aproveitamento: 0, vezesRespondido: 0, }, @@ -71,15 +71,15 @@ describe('HistoricoService.calcularMediaAproveitamento', () => { }); }); -describe('HistoricoService.getById (remap questoesNovo → simulado.questoes)', () => { - it('achata questoesNovo.questao em simulado.questoes e remove questoesNovo', async () => { +describe('HistoricoService.getById (achata simulado.questoes)', () => { + it('achata questoes.questao em simulado.questoes (subdoc → questao)', async () => { const historico = { toObject: () => ({ _id: 'h1', simulado: { _id: 's1', nome: 'S', - questoesNovo: [ + questoes: [ { questao: { _id: 'q1', textoQuestao: 'a', numero: 5, prova: 'p1' }, numero: 1 }, { questao: { _id: 'q2', textoQuestao: 'b', numero: 6, prova: 'p1' }, numero: 2 }, ], @@ -95,7 +95,6 @@ describe('HistoricoService.getById (remap questoesNovo → simulado.questoes)', { _id: 'q1', textoQuestao: 'a', numero: 5, prova: 'p1' }, { _id: 'q2', textoQuestao: 'b', numero: 6, prova: 'p1' }, ]); - expect(result.simulado.questoesNovo).toBeUndefined(); }); it('retorna null quando não encontra', async () => { diff --git a/src/modules/historico/historico.service.ts b/src/modules/historico/historico.service.ts index 30bba44..bdd3480 100644 --- a/src/modules/historico/historico.service.ts +++ b/src/modules/historico/historico.service.ts @@ -23,11 +23,10 @@ export class HistoricoService { const obj: any = (historico as any).toObject ? (historico as any).toObject() : historico; - if (obj.simulado && Array.isArray(obj.simulado.questoesNovo)) { - obj.simulado.questoes = obj.simulado.questoesNovo.map( + if (obj.simulado && Array.isArray(obj.simulado.questoes)) { + obj.simulado.questoes = obj.simulado.questoes.map( (qc: any) => qc.questao, ); - delete obj.simulado.questoesNovo; } return obj; } @@ -43,7 +42,7 @@ export class HistoricoService { performance: historico.aproveitamento, timeSpent: historico.tempoRealizado, questionsAnswered: historico.questoesRespondidas, - totalQuestionsTest: historico.simulado.questoesNovo.length, + totalQuestionsTest: historico.simulado.questoes.length, testPerformance: historico.simulado.aproveitamento, testAttempts: historico.simulado.vezesRespondido, createdAt: historico.createdAt, diff --git a/src/modules/prova/factory/custom_prova_factory.spec.ts b/src/modules/prova/factory/custom_prova_factory.spec.ts index 8ca4841..6a93e8b 100644 --- a/src/modules/prova/factory/custom_prova_factory.spec.ts +++ b/src/modules/prova/factory/custom_prova_factory.spec.ts @@ -252,7 +252,7 @@ describe('CustomProvaFactory.verifyNumberProva', () => { const { factory } = makeFactory({ getProvaWithQuestion: jest .fn() - .mockResolvedValue({ questoesNovo: [{ numero: 5 }] }), + .mockResolvedValue({ questoes: [{ numero: 5 }] }), }); expect(await factory.verifyNumberProva('p1', 5)).toBe(false); }); @@ -261,7 +261,7 @@ describe('CustomProvaFactory.verifyNumberProva', () => { const { factory } = makeFactory({ getProvaWithQuestion: jest .fn() - .mockResolvedValue({ questoesNovo: [{ numero: 5 }] }), + .mockResolvedValue({ questoes: [{ numero: 5 }] }), }); expect(await factory.verifyNumberProva('p1', 7)).toBe(true); }); @@ -272,7 +272,7 @@ describe('CustomProvaFactory.getMissingNumbers', () => { const { factory } = makeFactory(); const prova = { categoria: { quantidadeTotalQuestao: null }, - questoesNovo: [], + questoes: [], } as any; expect(await factory.getMissingNumbers(prova)).toEqual([]); }); @@ -281,7 +281,7 @@ describe('CustomProvaFactory.getMissingNumbers', () => { const { factory } = makeFactory(); const prova = { categoria: { quantidadeTotalQuestao: 4 }, - questoesNovo: [{ numero: 2 }, { numero: 4 }], + questoes: [{ numero: 2 }, { numero: 4 }], } as any; expect(await factory.getMissingNumbers(prova)).toEqual([1, 3]); }); @@ -291,12 +291,12 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { it('reconcilia o numero no subdoc da prova e do simulado quando o número muda', async () => { const simulado: any = { _id: 's1', - questoesNovo: [{ questao: { _id: 'qX' }, numero: 5 }], + questoes: [{ questao: { _id: 'qX' }, numero: 5 }], }; const prova: any = { _id: 'p1', simulados: [simulado], - questoesNovo: [{ questao: { _id: 'qX' }, numero: 5 }], + questoes: [{ questao: { _id: 'qX' }, numero: 5 }], }; const getById = jest.fn().mockResolvedValue(prova); const getByIdToUpdate = jest.fn().mockResolvedValue({ @@ -315,9 +315,9 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { prova: 'p1', } as any); - expect(prova.questoesNovo[0].numero).toBe(6); + expect(prova.questoes[0].numero).toBe(6); expect(provaRepository.update).toHaveBeenCalledWith(prova); - expect(simulado.questoesNovo[0].numero).toBe(6); + expect(simulado.questoes[0].numero).toBe(6); expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); }); @@ -325,7 +325,7 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { const prova: any = { _id: 'p1', simulados: [], - questoesNovo: [{ questao: { _id: 'qX' }, numero: 5 }], + questoes: [{ questao: { _id: 'qX' }, numero: 5 }], }; const getById = jest.fn().mockResolvedValue(prova); const getByIdToUpdate = jest.fn().mockResolvedValue({ diff --git a/src/modules/prova/factory/custom_prova_factory.ts b/src/modules/prova/factory/custom_prova_factory.ts index 242cd9e..778cfe7 100644 --- a/src/modules/prova/factory/custom_prova_factory.ts +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -74,7 +74,6 @@ export class CustomProvaFactory implements IProvaFactory { descricao: `${prova.categoria.exame.nome}`, categoria: prova.categoria, questoes: [], - questoesNovo: [], criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, } as any); @@ -163,7 +162,7 @@ export class CustomProvaFactory implements IProvaFactory { numberQuestion: number, ): Promise { const prova = await this.provaRepository.getProvaWithQuestion(id); - return !prova.questoesNovo.some((qc) => qc.numero === numberQuestion); + return !prova.questoes.some((qc) => qc.numero === numberQuestion); } public async getMissingNumbers(prova: Prova): Promise { @@ -173,7 +172,7 @@ export class CustomProvaFactory implements IProvaFactory { const missingQuestion: number[] = []; for (let i = 1; i <= prova.categoria.quantidadeTotalQuestao; i++) { - if (!prova.questoesNovo.find((qc) => qc.numero === i)) { + if (!prova.questoes.find((qc) => qc.numero === i)) { missingQuestion.push(i); } } diff --git a/src/modules/prova/factory/enem_2010_2016_factory.spec.ts b/src/modules/prova/factory/enem_2010_2016_factory.spec.ts index b12a75d..e393177 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.spec.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.spec.ts @@ -18,15 +18,15 @@ function makeFactory(getProvaWithQuestion?: jest.Mock) { describe('Enem2010_2017Factory.getMissingNumbers (Regra B — Dia 2, idiomáticas 91-95)', () => { it('marca como faltante a idiomática (91-95) que só tem 1 ocorrência', async () => { - const questoesNovo: any[] = []; - for (let n = 91; n <= 180; n++) questoesNovo.push({ numero: n, questao: {} }); - questoesNovo.push({ numero: 91, questao: {} }); // 91 agora com 2 + const questoes: any[] = []; + for (let n = 91; n <= 180; n++) questoes.push({ numero: n, questao: {} }); + questoes.push({ numero: 91, questao: {} }); // 91 agora com 2 const { factory } = makeFactory(); const missing = await factory.getMissingNumbers({ nome: 'Enem Dia 2 2015 PRIMEIRA 1', inicialNumero: 91, - questoesNovo, + questoes, } as any); expect(missing).toEqual([92, 93, 94, 95]); @@ -36,7 +36,7 @@ describe('Enem2010_2017Factory.getMissingNumbers (Regra B — Dia 2, idiomática describe('Enem2010_2017Factory.verifyNumberProva (Regra C — idiomáticas 91-95)', () => { it('numero livre → true', async () => { const { factory } = makeFactory( - jest.fn().mockResolvedValue({ questoesNovo: [] }), + jest.fn().mockResolvedValue({ questoes: [] }), ); expect(await factory.verifyNumberProva('p1', 120)).toBe(true); }); @@ -45,7 +45,7 @@ describe('Enem2010_2017Factory.verifyNumberProva (Regra C — idiomáticas 91-95 const { factory } = makeFactory( jest .fn() - .mockResolvedValue({ questoesNovo: [{ numero: 92, questao: {} }] }), + .mockResolvedValue({ questoes: [{ numero: 92, questao: {} }] }), ); expect(await factory.verifyNumberProva('p1', 92)).toBe(true); }); @@ -53,7 +53,7 @@ describe('Enem2010_2017Factory.verifyNumberProva (Regra C — idiomáticas 91-95 it('idiomático (91-95) com 2 ocorrências → false', async () => { const { factory } = makeFactory( jest.fn().mockResolvedValue({ - questoesNovo: [ + questoes: [ { numero: 92, questao: {} }, { numero: 92, questao: {} }, ], @@ -66,7 +66,7 @@ describe('Enem2010_2017Factory.verifyNumberProva (Regra C — idiomáticas 91-95 const { factory } = makeFactory( jest .fn() - .mockResolvedValue({ questoesNovo: [{ numero: 100, questao: {} }] }), + .mockResolvedValue({ questoes: [{ numero: 100, questao: {} }] }), ); expect(await factory.verifyNumberProva('p1', 100)).toBe(false); }); diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index fde31bf..8fb91de 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -72,7 +72,6 @@ export class Enem2010_2017Factory implements IProvaFactory { nome: `${mainName}`, categoria: prova.categoria, questoes: [], - questoesNovo: [], descricao: `${prova.categoria.exame.nome}`, criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, @@ -92,12 +91,12 @@ export class Enem2010_2017Factory implements IProvaFactory { const missingQuestion = []; if (day2) { for (let index = prova.inicialNumero; index <= 180; index++) { - if (!prova.questoesNovo.find((qc) => qc.numero === index)) { + if (!prova.questoes.find((qc) => qc.numero === index)) { missingQuestion.push(index); } else { if (index > 90 && index < 96) { const hasAllQuestion = - prova.questoesNovo.filter((qc) => qc.numero === index).length < 2; + prova.questoes.filter((qc) => qc.numero === index).length < 2; if (hasAllQuestion) { missingQuestion.push(index); } @@ -106,7 +105,7 @@ export class Enem2010_2017Factory implements IProvaFactory { } } else { for (let index = prova.inicialNumero; index <= 90; index++) { - if (!prova.questoesNovo.find((qc) => qc.numero === index)) { + if (!prova.questoes.find((qc) => qc.numero === index)) { missingQuestion.push(index); } } @@ -119,10 +118,10 @@ export class Enem2010_2017Factory implements IProvaFactory { numberQuestion: number, ): Promise { const prova = await this.provaRepository.getProvaWithQuestion(id); - if (prova.questoesNovo.some((qc) => qc.numero === numberQuestion)) { + if (prova.questoes.some((qc) => qc.numero === numberQuestion)) { if (numberQuestion > 90 && numberQuestion < 96) { return ( - prova.questoesNovo.filter((qc) => qc.numero === numberQuestion) + prova.questoes.filter((qc) => qc.numero === numberQuestion) .length < 2 ); } diff --git a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts index 8c9f3ac..d55907e 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts @@ -18,32 +18,32 @@ function makeFactory(getProvaWithQuestion?: jest.Mock) { describe('Enem2017PlusFactory.getMissingNumbers (Regra A — Dia 1, idiomáticas 1-5)', () => { it('marca como faltante a idiomática (1-5) que só tem 1 ocorrência', async () => { - const questoesNovo: any[] = []; - for (let n = 1; n <= 90; n++) questoesNovo.push({ numero: n, questao: {} }); - questoesNovo.push({ numero: 1, questao: {} }); // numero 1 agora tem 2 + const questoes: any[] = []; + for (let n = 1; n <= 90; n++) questoes.push({ numero: n, questao: {} }); + questoes.push({ numero: 1, questao: {} }); // numero 1 agora tem 2 const { factory } = makeFactory(); const missing = await factory.getMissingNumbers({ nome: 'Enem Dia 1 2020 PRIMEIRA 1', inicialNumero: 1, - questoesNovo, + questoes, } as any); expect(missing).toEqual([2, 3, 4, 5]); }); it('marca numero totalmente ausente como faltante', async () => { - const questoesNovo: any[] = []; + const questoes: any[] = []; for (let n = 1; n <= 90; n++) { if (n === 50) continue; - questoesNovo.push({ numero: n, questao: {} }); - if (n < 6) questoesNovo.push({ numero: n, questao: {} }); // 1-5 completas + questoes.push({ numero: n, questao: {} }); + if (n < 6) questoes.push({ numero: n, questao: {} }); // 1-5 completas } const { factory } = makeFactory(); const missing = await factory.getMissingNumbers({ nome: 'Enem Dia 1 2020 PRIMEIRA 1', inicialNumero: 1, - questoesNovo, + questoes, } as any); expect(missing).toEqual([50]); }); @@ -52,7 +52,7 @@ describe('Enem2017PlusFactory.getMissingNumbers (Regra A — Dia 1, idiomáticas describe('Enem2017PlusFactory.verifyNumberProva (Regra C)', () => { it('numero livre → true', async () => { const { factory } = makeFactory( - jest.fn().mockResolvedValue({ questoesNovo: [] }), + jest.fn().mockResolvedValue({ questoes: [] }), ); expect(await factory.verifyNumberProva('p1', 50)).toBe(true); }); @@ -61,7 +61,7 @@ describe('Enem2017PlusFactory.verifyNumberProva (Regra C)', () => { const { factory } = makeFactory( jest .fn() - .mockResolvedValue({ questoesNovo: [{ numero: 3, questao: {} }] }), + .mockResolvedValue({ questoes: [{ numero: 3, questao: {} }] }), ); expect(await factory.verifyNumberProva('p1', 3)).toBe(true); }); @@ -69,7 +69,7 @@ describe('Enem2017PlusFactory.verifyNumberProva (Regra C)', () => { it('idiomático (1-5) com 2 ocorrências → false', async () => { const { factory } = makeFactory( jest.fn().mockResolvedValue({ - questoesNovo: [ + questoes: [ { numero: 3, questao: {} }, { numero: 3, questao: {} }, ], @@ -82,7 +82,7 @@ describe('Enem2017PlusFactory.verifyNumberProva (Regra C)', () => { const { factory } = makeFactory( jest .fn() - .mockResolvedValue({ questoesNovo: [{ numero: 10, questao: {} }] }), + .mockResolvedValue({ questoes: [{ numero: 10, questao: {} }] }), ); expect(await factory.verifyNumberProva('p1', 10)).toBe(false); }); @@ -101,12 +101,12 @@ describe('Enem2017PlusFactory.updateQuestion — numero-sync (mudança pura de n it('reconcilia o numero no subdoc da prova e do simulado quando só o número muda', async () => { const simulado: any = { _id: 's1', - questoesNovo: [{ questao: { _id: 'qX' }, numero: 10 }], + questoes: [{ questao: { _id: 'qX' }, numero: 10 }], }; const prova: any = { _id: 'p1', simulados: [simulado], - questoesNovo: [{ questao: { _id: 'qX' }, numero: 10 }], + questoes: [{ questao: { _id: 'qX' }, numero: 10 }], }; const session = makeSession(); @@ -162,9 +162,9 @@ describe('Enem2017PlusFactory.updateQuestion — numero-sync (mudança pura de n expect(simuladoService.addQuestionSimulados).not.toHaveBeenCalled(); expect(simuladoService.removeQuestionSimulados).not.toHaveBeenCalled(); // numero-sync reconciliou prova + simulado - expect(prova.questoesNovo[0].numero).toBe(11); + expect(prova.questoes[0].numero).toBe(11); expect(provaRepository.update).toHaveBeenCalledWith(prova); - expect(simulado.questoesNovo[0].numero).toBe(11); + expect(simulado.questoes[0].numero).toBe(11); expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); }); }); diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index 9cd2a6c..c5e0309 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -79,7 +79,6 @@ export class Enem2017PlusFactory implements IProvaFactory { nome: `${mainName}`, categoria: prova.categoria, questoes: [], - questoesNovo: [], descricao: `${prova.categoria.exame.nome}`, criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, @@ -92,12 +91,12 @@ export class Enem2017PlusFactory implements IProvaFactory { const missingQuestion = []; if (day1) { for (let index = prova.inicialNumero; index <= 90; index++) { - if (!prova.questoesNovo.find((qc) => qc.numero === index)) { + if (!prova.questoes.find((qc) => qc.numero === index)) { missingQuestion.push(index); } else { if (index < 6) { const hasAllQuestion = - prova.questoesNovo.filter((qc) => qc.numero === index).length < 2; + prova.questoes.filter((qc) => qc.numero === index).length < 2; if (hasAllQuestion) { missingQuestion.push(index); } @@ -106,7 +105,7 @@ export class Enem2017PlusFactory implements IProvaFactory { } } else { for (let index = prova.inicialNumero; index <= 180; index++) { - if (!prova.questoesNovo.find((qc) => qc.numero === index)) { + if (!prova.questoes.find((qc) => qc.numero === index)) { missingQuestion.push(index); } } @@ -119,10 +118,10 @@ export class Enem2017PlusFactory implements IProvaFactory { numberQuestion: number, ): Promise { const prova = await this.provaRepository.getProvaWithQuestion(id); - if (prova.questoesNovo.some((qc) => qc.numero === numberQuestion)) { + if (prova.questoes.some((qc) => qc.numero === numberQuestion)) { if (numberQuestion < 6) { return ( - prova.questoesNovo.filter((qc) => qc.numero === numberQuestion) + prova.questoes.filter((qc) => qc.numero === numberQuestion) .length < 2 ); } diff --git a/src/modules/prova/helpers/question-container.helpers.spec.ts b/src/modules/prova/helpers/question-container.helpers.spec.ts index 1e04d0a..af2ab0f 100644 --- a/src/modules/prova/helpers/question-container.helpers.spec.ts +++ b/src/modules/prova/helpers/question-container.helpers.spec.ts @@ -7,35 +7,35 @@ import { describe('question-container.helpers', () => { it('addQuestaoToContainer empurra { questao, numero } com o objeto completo', () => { - const container: any = { questoesNovo: [] }; + const container: any = { questoes: [] }; const questao: any = { _id: new Types.ObjectId(), numero: 7, status: 'approved' }; addQuestaoToContainer(container, questao); - expect(container.questoesNovo).toHaveLength(1); - expect(container.questoesNovo[0].numero).toBe(7); + expect(container.questoes).toHaveLength(1); + expect(container.questoes[0].numero).toBe(7); // objeto completo preservado (permite ler .status em memória) - expect(container.questoesNovo[0].questao).toBe(questao); - expect(container.questoesNovo[0].questao.status).toBe('approved'); + expect(container.questoes[0].questao).toBe(questao); + expect(container.questoes[0].questao.status).toBe('approved'); }); it('permite duas questões com o mesmo numero (idiomáticas ENEM, sem dedupe)', () => { - const container: any = { questoesNovo: [] }; + const container: any = { questoes: [] }; const q1: any = { _id: new Types.ObjectId(), numero: 1 }; const q2: any = { _id: new Types.ObjectId(), numero: 1 }; addQuestaoToContainer(container, q1); addQuestaoToContainer(container, q2); - expect(container.questoesNovo).toHaveLength(2); - expect(container.questoesNovo.map((qc: any) => qc.numero)).toEqual([1, 1]); + expect(container.questoes).toHaveLength(2); + expect(container.questoes.map((qc: any) => qc.numero)).toEqual([1, 1]); }); it('removeQuestaoFromContainer remove por id quando questao é objeto completo', () => { const id = new Types.ObjectId(); const outro = new Types.ObjectId(); const container: any = { - questoesNovo: [ + questoes: [ { questao: { _id: id }, numero: 1 }, { questao: { _id: outro }, numero: 2 }, ], @@ -43,15 +43,15 @@ describe('question-container.helpers', () => { removeQuestaoFromContainer(container, id); - expect(container.questoesNovo).toHaveLength(1); - expect(container.questoesNovo[0].questao._id).toBe(outro); + expect(container.questoes).toHaveLength(1); + expect(container.questoes[0].questao._id).toBe(outro); }); it('removeQuestaoFromContainer remove por id quando questao é ObjectId cru (ref não populada)', () => { const id = new Types.ObjectId(); const outro = new Types.ObjectId(); const container: any = { - questoesNovo: [ + questoes: [ { questao: id, numero: 1 }, { questao: outro, numero: 2 }, ], @@ -59,8 +59,8 @@ describe('question-container.helpers', () => { removeQuestaoFromContainer(container, id); - expect(container.questoesNovo).toHaveLength(1); - expect(container.questoesNovo[0].questao).toBe(outro); + expect(container.questoes).toHaveLength(1); + expect(container.questoes[0].questao).toBe(outro); }); }); @@ -69,7 +69,7 @@ describe('updateNumeroNoContainer', () => { const alvo = new Types.ObjectId(); const outro = new Types.ObjectId(); const container: any = { - questoesNovo: [ + questoes: [ { questao: { _id: alvo }, numero: 5 }, { questao: { _id: outro }, numero: 6 }, ], @@ -78,37 +78,37 @@ describe('updateNumeroNoContainer', () => { const changed = updateNumeroNoContainer(container, alvo, 42); expect(changed).toBe(true); - expect(container.questoesNovo[0].numero).toBe(42); - expect(container.questoesNovo[1].numero).toBe(6); + expect(container.questoes[0].numero).toBe(42); + expect(container.questoes[1].numero).toBe(6); }); it('funciona com questao sendo ObjectId cru (ref não populada)', () => { const alvo = new Types.ObjectId(); const container: any = { - questoesNovo: [{ questao: alvo, numero: 5 }], + questoes: [{ questao: alvo, numero: 5 }], }; const changed = updateNumeroNoContainer(container, alvo, 9); expect(changed).toBe(true); - expect(container.questoesNovo[0].numero).toBe(9); + expect(container.questoes[0].numero).toBe(9); }); it('retorna false quando o numero já é o esperado (no-op)', () => { const alvo = new Types.ObjectId(); const container: any = { - questoesNovo: [{ questao: { _id: alvo }, numero: 7 }], + questoes: [{ questao: { _id: alvo }, numero: 7 }], }; const changed = updateNumeroNoContainer(container, alvo, 7); expect(changed).toBe(false); - expect(container.questoesNovo[0].numero).toBe(7); + expect(container.questoes[0].numero).toBe(7); }); it('retorna false quando a questão não está no container', () => { const container: any = { - questoesNovo: [{ questao: { _id: new Types.ObjectId() }, numero: 1 }], + questoes: [{ questao: { _id: new Types.ObjectId() }, numero: 1 }], }; const changed = updateNumeroNoContainer(container, new Types.ObjectId(), 3); diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts index 27157ca..5c583b2 100644 --- a/src/modules/prova/helpers/question-container.helpers.ts +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -3,12 +3,12 @@ import { Questao } from '../../questao/questao.schema'; import { QuestaoNaContainer } from '../schemas/questao-na-container.schema'; interface QuestaoContainer { - questoesNovo: QuestaoNaContainer[]; + questoes: QuestaoNaContainer[]; } /** * Adiciona a questão ao container (Prova ou Simulado) escrevendo SÓ em - * `questoesNovo` (cutover, single-write). Guarda o objeto Questao completo — + * `questoes` (cutover, single-write). Guarda o objeto Questao completo — * o Mongoose casta para `_id` na persistência, e em memória preserva a leitura * de `qc.questao.status`/`.numero` usada nas factories/addQuestionSimulados. */ @@ -16,7 +16,7 @@ export function addQuestaoToContainer( container: QuestaoContainer, questao: Questao, ): void { - container.questoesNovo.push({ questao, numero: questao.numero }); + container.questoes.push({ questao, numero: questao.numero }); } /** @@ -29,7 +29,7 @@ export function removeQuestaoFromContainer( questaoId: Types.ObjectId | string, ): void { const idStr = questaoId.toString(); - container.questoesNovo = container.questoesNovo.filter((qc) => { + container.questoes = container.questoes.filter((qc) => { const cur = (qc.questao as any)?._id ?? qc.questao; return cur.toString() !== idStr; }); @@ -48,7 +48,7 @@ export function updateNumeroNoContainer( ): boolean { const idStr = questaoId.toString(); let changed = false; - for (const qc of container.questoesNovo) { + for (const qc of container.questoes) { const cur = (qc.questao as any)?._id ?? qc.questao; if (cur.toString() === idStr && qc.numero !== numero) { qc.numero = numero; diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts index 010e7cc..eec0e30 100644 --- a/src/modules/prova/prova.repository.spec.ts +++ b/src/modules/prova/prova.repository.spec.ts @@ -2,11 +2,11 @@ import { Types } from 'mongoose'; import { Status } from '../questao/enums/status.enum'; import { ProvaRepository } from './prova.repository'; -describe('ProvaRepository.addQuestion (single-write questoesNovo)', () => { - it('empurra a questão em questoesNovo e incrementa totalQuestaoValidadas quando aprovada', async () => { +describe('ProvaRepository.addQuestion (single-write questoes)', () => { + it('empurra a questão em questoes e incrementa totalQuestaoValidadas quando aprovada', async () => { const prova: any = { _id: 'p1', - questoesNovo: [], + questoes: [], totalQuestaoValidadas: 0, }; const updateOne = jest.fn().mockResolvedValue({ acknowledged: true }); @@ -20,8 +20,8 @@ describe('ProvaRepository.addQuestion (single-write questoesNovo)', () => { }; await repo.addQuestion('p1', questao); - expect(prova.questoesNovo).toHaveLength(1); - expect(prova.questoesNovo[0].numero).toBe(3); + expect(prova.questoes).toHaveLength(1); + expect(prova.questoes[0].numero).toBe(3); expect(prova.totalQuestaoValidadas).toBe(1); expect(updateOne).toHaveBeenCalledWith({ _id: 'p1' }, prova); }); @@ -29,7 +29,7 @@ describe('ProvaRepository.addQuestion (single-write questoesNovo)', () => { it('não incrementa totalQuestaoValidadas quando a questão não está aprovada', async () => { const prova: any = { _id: 'p1', - questoesNovo: [], + questoes: [], totalQuestaoValidadas: 0, }; const findById = jest.fn().mockResolvedValue(prova); @@ -48,12 +48,12 @@ describe('ProvaRepository.addQuestion (single-write questoesNovo)', () => { }); }); -describe('ProvaRepository.removeQuestion (single-write questoesNovo)', () => { - it('remove de questoesNovo e decrementa totalQuestaoValidadas quando aprovada', async () => { +describe('ProvaRepository.removeQuestion (single-write questoes)', () => { + it('remove de questoes e decrementa totalQuestaoValidadas quando aprovada', async () => { const alvo = new Types.ObjectId(); const prova: any = { _id: 'p1', - questoesNovo: [{ questao: { _id: alvo }, numero: 1 }], + questoes: [{ questao: { _id: alvo }, numero: 1 }], totalQuestaoValidadas: 1, }; const updateOne = jest.fn().mockResolvedValue({ acknowledged: true }); @@ -65,7 +65,7 @@ describe('ProvaRepository.removeQuestion (single-write questoesNovo)', () => { status: Status.Approved, } as any); - expect(prova.questoesNovo).toHaveLength(0); + expect(prova.questoes).toHaveLength(0); expect(prova.totalQuestaoValidadas).toBe(0); expect(updateOne).toHaveBeenCalledWith({ _id: 'p1' }, prova); }); @@ -73,7 +73,7 @@ describe('ProvaRepository.removeQuestion (single-write questoesNovo)', () => { it('não decrementa quando a questão não estava no container', async () => { const prova: any = { _id: 'p1', - questoesNovo: [{ questao: { _id: new Types.ObjectId() }, numero: 1 }], + questoes: [{ questao: { _id: new Types.ObjectId() }, numero: 1 }], totalQuestaoValidadas: 1, }; const findById = jest.fn().mockResolvedValue(prova); @@ -87,13 +87,13 @@ describe('ProvaRepository.removeQuestion (single-write questoesNovo)', () => { status: Status.Approved, } as any); - expect(prova.questoesNovo).toHaveLength(1); + expect(prova.questoes).toHaveLength(1); expect(prova.totalQuestaoValidadas).toBe(1); }); }); -describe('ProvaRepository.getById (popula questoesNovo.questao)', () => { - it('popula questoesNovo.questao no topo e aninhado nos simulados', async () => { +describe('ProvaRepository.getById (popula questoes.questao)', () => { + it('popula questoes.questao no topo e aninhado nos simulados', async () => { const populateCalls: any[] = []; const query: any = {}; query.populate = jest.fn((arg: any) => { @@ -108,15 +108,15 @@ describe('ProvaRepository.getById (popula questoesNovo.questao)', () => { await repo.getById('p1'); expect(findById).toHaveBeenCalledWith('p1'); - // populate top-level de questoesNovo.questao - expect(populateCalls).toContain('questoesNovo.questao'); + // populate top-level de questoes.questao + expect(populateCalls).toContain('questoes.questao'); expect(populateCalls).not.toContain('questoes'); - // populate aninhado nos simulados inclui questoesNovo.questao + // populate aninhado nos simulados inclui questoes.questao const nested = populateCalls.find( (c) => c && typeof c === 'object' && c.path === 'simulados', ); expect(nested).toBeDefined(); - expect(nested.populate).toEqual(['categoria', { path: 'questoesNovo.questao' }]); + expect(nested.populate).toEqual(['categoria', { path: 'questoes.questao' }]); }); }); diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index b3fc535..bb0f486 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -33,11 +33,11 @@ export class ProvaRepository extends BaseRepository { return await this.model .findById(id) .populate('simulados') - .populate('questoesNovo.questao') + .populate('questoes.questao') .populate({ path: 'categoria', populate: 'exame' }) .populate({ path: 'simulados', - populate: ['categoria', { path: 'questoesNovo.questao' }], + populate: ['categoria', { path: 'questoes.questao' }], }); } @@ -52,10 +52,10 @@ export class ProvaRepository extends BaseRepository { public async removeQuestion(id: string, oldQuestao: Questao) { const prova = await this.model.findById(id); - const before = prova.questoesNovo.length; + const before = prova.questoes.length; removeQuestaoFromContainer(prova, oldQuestao._id); if ( - prova.questoesNovo.length !== before && + prova.questoes.length !== before && oldQuestao.status === Status.Approved ) { prova.totalQuestaoValidadas -= 1; diff --git a/src/modules/prova/prova.schema.ts b/src/modules/prova/prova.schema.ts index 143cf67..a705555 100644 --- a/src/modules/prova/prova.schema.ts +++ b/src/modules/prova/prova.schema.ts @@ -1,7 +1,6 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import mongoose, { Types } from 'mongoose'; import { BaseSchema } from 'src/shared/base/base.schema'; -import { Questao } from '../questao/questao.schema'; import { Simulado } from '../simulado/schemas/simulado.schema'; import { Categoria } from '../categoria/schemas/categoria.schema'; import { CreateProvaDTOInput } from './dtos/create.dto.input'; @@ -25,7 +24,6 @@ export class Prova extends BaseSchema { this.cursinhoId = item.cursinhoId ?? null; this.simulados = []; this.questoes = []; - this.questoesNovo = []; } @Prop({ enum: Edicao }) @@ -46,14 +44,8 @@ export class Prova extends BaseSchema { }) public simulados: Simulado[]; - @Prop({ - type: [{ ref: 'Questao', type: mongoose.Schema.Types.ObjectId }], - default: [], - }) - questoes: Questao[]; - @Prop({ type: [QuestaoNaContainerSchema], default: [] }) - public questoesNovo: QuestaoNaContainer[]; + public questoes: QuestaoNaContainer[]; @Prop() public nome: string; diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts index 6e11d57..aecf8bd 100644 --- a/src/modules/prova/prova.service.spec.ts +++ b/src/modules/prova/prova.service.spec.ts @@ -21,12 +21,12 @@ describe('ProvaService.approvedQuestion — regra bloqueado com qtd null', () => it('desbloqueia simulado de categoria livre (null) quando todas aprovadas', async () => { const simulado: any = { _id: 's1', - questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], + questoes: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], categoria: { quantidadeTotalQuestao: null }, bloqueado: true, }; const prova = { - questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], + questoes: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], simulados: [simulado], } as any; const { service } = makeService({ @@ -42,12 +42,12 @@ describe('ProvaService.approvedQuestion — regra bloqueado com qtd null', () => it('mantém bloqueado quando categoria numérica ainda não atingiu a quantidade', async () => { const simulado: any = { _id: 's1', - questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], + questoes: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], categoria: { quantidadeTotalQuestao: 30 }, bloqueado: true, }; const prova = { - questoesNovo: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], + questoes: [{ questao: { _id: 'q1', status: Status.Pending }, numero: 1 }], simulados: [simulado], }; const { service } = makeService({ @@ -74,7 +74,7 @@ describe('ProvaService.getAllByCursinho', () => { totalQuestaoValidadas: 0, filename: 'f.pdf', enemAreas: [] as any[], - questoesNovo: [] as any[], + questoes: [] as any[], createdAt: new Date('2024-01-01T00:00:00.000Z'), }; } @@ -136,11 +136,11 @@ describe('ProvaService.getAllByCursinho', () => { }); }); -describe('ProvaService.refuseQuestion — recompute questoesNovo', () => { +describe('ProvaService.refuseQuestion — recompute questoes', () => { it('exclui a questão recusada da contagem e bloqueia o simulado', async () => { const simulado: any = { _id: 's1', - questoesNovo: [ + questoes: [ { questao: { _id: 'q1', status: Status.Approved }, numero: 1 }, { questao: { _id: 'q2', status: Status.Approved }, numero: 2 }, ], @@ -148,7 +148,7 @@ describe('ProvaService.refuseQuestion — recompute questoesNovo', () => { bloqueado: false, }; const prova = { - questoesNovo: [ + questoes: [ { questao: { _id: 'q1', status: Status.Approved }, numero: 1 }, { questao: { _id: 'q2', status: Status.Approved }, numero: 2 }, ], diff --git a/src/modules/prova/prova.service.ts b/src/modules/prova/prova.service.ts index 37480ad..7bac2a4 100644 --- a/src/modules/prova/prova.service.ts +++ b/src/modules/prova/prova.service.ts @@ -41,7 +41,7 @@ export class ProvaService { exame: result.categoria.exame.nome, nome: result.nome, totalQuestao: result.totalQuestao, - totalQuestaoCadastradas: result.questoesNovo.length, + totalQuestaoCadastradas: result.questoes.length, totalQuestaoValidadas: result.totalQuestaoValidadas, filename: result.filename, gabarito: result.gabarito, @@ -72,7 +72,7 @@ export class ProvaService { totalQuestaoValidadas: prova.totalQuestaoValidadas, filename: prova.filename, enemAreas: prova.enemAreas, - totalQuestaoCadastradas: prova.questoesNovo.length, + totalQuestaoCadastradas: prova.questoes.length, createdAt: prova.createdAt, } as GetProvaDTOOutout; } @@ -105,23 +105,23 @@ export class ProvaService { const prova = await this.repository.getById(id); // Recalcula totalQuestaoValidadas contando a questão sendo aprovada - prova.totalQuestaoValidadas = prova.questoesNovo.filter((qc) => { + prova.totalQuestaoValidadas = prova.questoes.filter((qc) => { if (qc.questao._id.toString() === questionId) return true; return qc.questao.status === Status.Approved; }).length; await Promise.all( prova.simulados.map(async (simulado) => { - const containsQuestion = simulado.questoesNovo.find( + const containsQuestion = simulado.questoes.find( (qc) => qc.questao._id.toString() === questionId, ); if (!containsQuestion) return; const hasRequiredCount = atingiuQuantidade( simulado.categoria.quantidadeTotalQuestao, - simulado.questoesNovo.length, + simulado.questoes.length, ); - const allApproved = simulado.questoesNovo.every( + const allApproved = simulado.questoes.every( (qc) => qc.questao.status === Status.Approved || qc.questao._id.toString() === questionId, @@ -129,7 +129,7 @@ export class ProvaService { simulado.bloqueado = !(hasRequiredCount && allApproved); if (!simulado.bloqueado) { - simulado.questoesNovo = simulado.questoesNovo.sort( + simulado.questoes = simulado.questoes.sort( (a, b) => a.numero - b.numero, ); } @@ -143,14 +143,14 @@ export class ProvaService { const prova = await this.repository.getById(id); // Recalcula totalQuestaoValidadas excluindo a questão sendo rejeitada - prova.totalQuestaoValidadas = prova.questoesNovo.filter((qc) => { + prova.totalQuestaoValidadas = prova.questoes.filter((qc) => { if (qc.questao._id.toString() === questionId) return false; return qc.questao.status === Status.Approved; }).length; await Promise.all( prova.simulados.map(async (simulado) => { - const containsQuestion = simulado.questoesNovo.some( + const containsQuestion = simulado.questoes.some( (qc) => qc.questao._id.toString() === questionId, ); if (!containsQuestion) return; @@ -158,11 +158,11 @@ export class ProvaService { // Recalcula bloqueado considerando a questão sendo rejeitada const hasRequiredCount = atingiuQuantidade( simulado.categoria.quantidadeTotalQuestao, - simulado.questoesNovo.length, + simulado.questoes.length, ); const allApproved = hasRequiredCount && - simulado.questoesNovo.every((qc) => { + simulado.questoes.every((qc) => { if (qc.questao._id.toString() === questionId) return false; return qc.questao.status === Status.Approved; }); @@ -237,7 +237,7 @@ export class ProvaService { exame: prova.categoria.exame.nome, nome: prova.nome, totalQuestao: prova.totalQuestao, - totalQuestaoCadastradas: prova.questoesNovo.length, + totalQuestaoCadastradas: prova.questoes.length, totalQuestaoValidadas: prova.totalQuestaoValidadas, filename: prova.filename, gabarito: prova.gabarito, diff --git a/src/modules/prova/services/enem_service.ts b/src/modules/prova/services/enem_service.ts index fc8898e..399f729 100644 --- a/src/modules/prova/services/enem_service.ts +++ b/src/modules/prova/services/enem_service.ts @@ -25,7 +25,6 @@ export class EnemService { nome: `${mainName} Inglês`, categoria: prova.categoria, questoes: [], - questoesNovo: [], descricao: `${prova.categoria.exame.nome} Inglês`, criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, @@ -36,7 +35,6 @@ export class EnemService { nome: `${mainName} Espanhol`, categoria: prova.categoria, questoes: [], - questoesNovo: [], descricao: `${prova.categoria.exame.nome} Espanhol`, criadorId: prova.criadorId, cursinhoId: prova.cursinhoId, diff --git a/src/modules/simulado/schemas/simulado.schema.ts b/src/modules/simulado/schemas/simulado.schema.ts index ae5deaf..d28fd96 100644 --- a/src/modules/simulado/schemas/simulado.schema.ts +++ b/src/modules/simulado/schemas/simulado.schema.ts @@ -1,8 +1,7 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { ApiProperty } from '@nestjs/swagger'; -import mongoose, { Types } from 'mongoose'; +import { Types } from 'mongoose'; import { BaseSchema } from 'src/shared/base/base.schema'; -import { Questao } from '../../questao/questao.schema'; import { Categoria } from '../../categoria/schemas/categoria.schema'; import { QuestaoNaContainer, @@ -23,17 +22,9 @@ export class Simulado extends BaseSchema { @ApiProperty() categoria: Categoria; - @Prop({ - type: [{ ref: 'Questao', type: mongoose.Schema.Types.ObjectId }], - default: [], - required: false, - }) - @ApiProperty({ type: Questao, isArray: true }) - questoes: Questao[]; - @Prop({ type: [QuestaoNaContainerSchema], default: [] }) @ApiProperty({ type: QuestaoNaContainer, isArray: true, required: false }) - questoesNovo: QuestaoNaContainer[]; + questoes: QuestaoNaContainer[]; @Prop({ required: false, default: 0 }) @ApiProperty() diff --git a/src/modules/simulado/simulado.repository.spec.ts b/src/modules/simulado/simulado.repository.spec.ts index cac2082..4151bae 100644 --- a/src/modules/simulado/simulado.repository.spec.ts +++ b/src/modules/simulado/simulado.repository.spec.ts @@ -91,9 +91,9 @@ describe('SimuladoRepository.getAvailabilityById', () => { }); }); -describe('SimuladoRepository.answer (popula questoesNovo.questao)', () => { - it('popula questoesNovo.questao com frente1/materia e alternativa', async () => { - const exec = jest.fn().mockResolvedValue({ _id: 's1', questoesNovo: [] }); +describe('SimuladoRepository.answer (popula questoes.questao)', () => { + it('popula questoes.questao com frente1/materia e alternativa', async () => { + const exec = jest.fn().mockResolvedValue({ _id: 's1', questoes: [] }); const populate = jest.fn().mockReturnValue({ exec }); const findById = jest.fn().mockReturnValue({ populate }); const repo = new SimuladoRepository({ findById } as any); @@ -102,15 +102,15 @@ describe('SimuladoRepository.answer (popula questoesNovo.questao)', () => { expect(findById).toHaveBeenCalledWith('s1'); expect(populate).toHaveBeenCalledWith({ - path: 'questoesNovo.questao', + path: 'questoes.questao', populate: ['frente1', 'materia'], select: 'alternativa', }); }); }); -describe('SimuladoRepository.getById (popula questoesNovo.questao, sem questoes)', () => { - it('popula categoria + questoesNovo.questao e NÃO popula questoes', async () => { +describe('SimuladoRepository.getById (popula questoes.questao, sem questoes)', () => { + it('popula categoria + questoes.questao e NÃO popula questoes', async () => { const populateArgs: any[] = []; const exec = jest.fn().mockResolvedValue({ _id: 's1' }); const query: any = { exec }; @@ -124,7 +124,7 @@ describe('SimuladoRepository.getById (popula questoesNovo.questao, sem questoes) await repo.getById('s1'); expect(populateArgs).toContainEqual({ - path: 'questoesNovo.questao', + path: 'questoes.questao', populate: ['frente1', 'materia'], }); expect( diff --git a/src/modules/simulado/simulado.repository.ts b/src/modules/simulado/simulado.repository.ts index f9beb0e..491b8b4 100644 --- a/src/modules/simulado/simulado.repository.ts +++ b/src/modules/simulado/simulado.repository.ts @@ -24,7 +24,7 @@ export class SimuladoRepository extends BaseRepository { .findById(id) .populate('categoria') .populate({ - path: 'questoesNovo.questao', + path: 'questoes.questao', populate: ['frente1', 'materia'], }) .exec(); @@ -44,7 +44,7 @@ export class SimuladoRepository extends BaseRepository { return await this.model .findById(id) .populate({ - path: 'questoesNovo.questao', + path: 'questoes.questao', populate: ['frente1', 'materia'], select: 'alternativa', }) diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index c2e3a1e..0948f8f 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -92,7 +92,7 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { disponivelDe: null, disponivelAte: null, categoria: { _id: 'c1', duracao: 60 }, - questoesNovo: [], + questoes: [], } as any; const service = makeService({ getAvailabilityById: jest.fn().mockResolvedValue({ @@ -106,7 +106,7 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { expect(result).toMatchObject({ _id: 's1', nome: 'S', duracao: 60 }); }); - it('monta questoes a partir de questoesNovo (numero do subdoc)', async () => { + it('monta questoes a partir de questoes (numero do subdoc)', async () => { const simulado = { _id: 's1', nome: 'S', @@ -115,7 +115,7 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { disponivelDe: null, disponivelAte: null, categoria: { _id: 'c1', duracao: 60 }, - questoesNovo: [ + questoes: [ { questao: { _id: 'q1', @@ -220,13 +220,13 @@ describe('SimuladoService.updateDisponibilidade', () => { }); }); -describe('SimuladoService.addQuestionSimulados (single-write questoesNovo)', () => { - it('empurra em questoesNovo e desbloqueia quando atinge quantidade e todas aprovadas', async () => { +describe('SimuladoService.addQuestionSimulados (single-write questoes)', () => { + it('empurra em questoes e desbloqueia quando atinge quantidade e todas aprovadas', async () => { const updateSession = jest.fn().mockResolvedValue(undefined); const service = makeService({ updateSession }); const sml: any = { _id: 's1', - questoesNovo: [], + questoes: [], categoria: { quantidadeTotalQuestao: 1 }, bloqueado: true, }; @@ -238,7 +238,7 @@ describe('SimuladoService.addQuestionSimulados (single-write questoesNovo)', () await service.addQuestionSimulados([sml], question); - expect(sml.questoesNovo).toHaveLength(1); + expect(sml.questoes).toHaveLength(1); expect(sml.bloqueado).toBe(false); expect(updateSession).toHaveBeenCalledWith(sml, undefined); }); @@ -249,7 +249,7 @@ describe('SimuladoService.addQuestionSimulados (single-write questoesNovo)', () }); const sml: any = { _id: 's1', - questoesNovo: [], + questoes: [], categoria: { quantidadeTotalQuestao: 30 }, bloqueado: true, }; @@ -264,19 +264,19 @@ describe('SimuladoService.addQuestionSimulados (single-write questoesNovo)', () }); }); -describe('SimuladoService.removeQuestionSimulados (single-write questoesNovo)', () => { - it('remove de questoesNovo e bloqueia o simulado', async () => { +describe('SimuladoService.removeQuestionSimulados (single-write questoes)', () => { + it('remove de questoes e bloqueia o simulado', async () => { const updateSession = jest.fn().mockResolvedValue(undefined); const service = makeService({ updateSession }); const sml: any = { _id: 's1', - questoesNovo: [{ questao: { _id: 'q1' }, numero: 1 }], + questoes: [{ questao: { _id: 'q1' }, numero: 1 }], bloqueado: false, }; await service.removeQuestionSimulados([sml], { _id: 'q1' } as any); - expect(sml.questoesNovo).toHaveLength(0); + expect(sml.questoes).toHaveLength(0); expect(sml.bloqueado).toBe(true); expect(updateSession).toHaveBeenCalledWith(sml, undefined); }); @@ -286,20 +286,20 @@ describe('SimuladoService.removeQuestionSimulados (single-write questoesNovo)', const service = makeService({ updateSession }); const sml: any = { _id: 's1', - questoesNovo: [{ questao: { _id: 'outra' }, numero: 1 }], + questoes: [{ questao: { _id: 'outra' }, numero: 1 }], bloqueado: false, }; await service.removeQuestionSimulados([sml], { _id: 'q1' } as any); - expect(sml.questoesNovo).toHaveLength(1); + expect(sml.questoes).toHaveLength(1); expect(sml.bloqueado).toBe(false); expect(updateSession).not.toHaveBeenCalled(); }); }); -describe('SimuladoService.processAnswer (lê questoesNovo)', () => { - it('mapeia respostas a partir de questoesNovo e completa o histórico', async () => { +describe('SimuladoService.processAnswer (lê questoes)', () => { + it('mapeia respostas a partir de questoes e completa o histórico', async () => { const questao: any = { _id: { toString: () => 'q1' }, alternativa: 'A', @@ -308,7 +308,7 @@ describe('SimuladoService.processAnswer (lê questoesNovo)', () => { }; const simulado: any = { _id: 's1', - questoesNovo: [{ questao, numero: 1 }], + questoes: [{ questao, numero: 1 }], }; const historicoRepository: any = { @@ -339,9 +339,9 @@ describe('SimuladoService.processAnswer (lê questoesNovo)', () => { await service.processAnswer('hist1'); - // buscou o ano pela primeira questão de questoesNovo + // buscou o ano pela primeira questão de questoes expect(questoesRepository.getById).toHaveBeenCalledWith(questao._id); - // completou com 1 resposta mapeada de questoesNovo + // completou com 1 resposta mapeada de questoes const payload = historicoRepository.completeProcessing.mock.calls[0][1]; expect(payload.ano).toBe(2023); expect(payload.respostas).toHaveLength(1); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 451cea7..5969393 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -129,17 +129,17 @@ export class SimuladoService { ) { await Promise.all( simulados.map(async (sml) => { - // Adiciona a nova questão (single-write em questoesNovo). + // Adiciona a nova questão (single-write em questoes). addQuestaoToContainer(sml, question); // Verifica se o simulado atingiu a quantidade total de questões // (categoria livre / quantidadeTotalQuestao null sempre "atinge") const atingiuQuantidadeTotal = atingiuQuantidade( sml.categoria.quantidadeTotalQuestao, - sml.questoesNovo.length, + sml.questoes.length, ); // Verifica se todas as questões estão aprovadas - const todasAprovadas = sml.questoesNovo.every( + const todasAprovadas = sml.questoes.every( (qc) => qc.questao.status === Status.Approved, ); @@ -158,9 +158,9 @@ export class SimuladoService { ) { await Promise.all( simulados.map(async (sml) => { - const before = sml.questoesNovo.length; + const before = sml.questoes.length; removeQuestaoFromContainer(sml, question._id); - if (sml.questoesNovo.length !== before) { + if (sml.questoes.length !== before) { sml.bloqueado = true; await this.simuladoRepository.updateSession(sml, session); } @@ -208,12 +208,12 @@ export class SimuladoService { const ano = ( await this.questoesRepository.getById( - simulado.questoesNovo[0].questao._id, + simulado.questoes[0].questao._id, ) ).prova.ano; const respostasAproveitamento: RespostaAproveitamento[] = - simulado.questoesNovo.map((qc) => { + simulado.questoes.map((qc) => { const questao = qc.questao; const resposta = historico.rawRespostas!.find( (r: any) => r.questao === questao._id.toString(), @@ -271,7 +271,7 @@ export class SimuladoService { nome: simulado.nome, descricao: simulado.descricao, categoria: simulado.categoria._id, - questoes: simulado.questoesNovo.map((qc) => ({ + questoes: simulado.questoes.map((qc) => ({ _id: qc.questao._id, enemArea: qc.questao.enemArea, frente1: qc.questao.frente1, From 5e37f43d19530c32bf39cbe10b69b35a3296baba Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 19:47:41 -0300 Subject: [PATCH 33/65] test: remove asserts tautologicos de populate pos-rename questoes Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.repository.spec.ts | 1 - src/modules/simulado/simulado.repository.spec.ts | 9 ++------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts index eec0e30..a591788 100644 --- a/src/modules/prova/prova.repository.spec.ts +++ b/src/modules/prova/prova.repository.spec.ts @@ -110,7 +110,6 @@ describe('ProvaRepository.getById (popula questoes.questao)', () => { expect(findById).toHaveBeenCalledWith('p1'); // populate top-level de questoes.questao expect(populateCalls).toContain('questoes.questao'); - expect(populateCalls).not.toContain('questoes'); // populate aninhado nos simulados inclui questoes.questao const nested = populateCalls.find( (c) => c && typeof c === 'object' && c.path === 'simulados', diff --git a/src/modules/simulado/simulado.repository.spec.ts b/src/modules/simulado/simulado.repository.spec.ts index 4151bae..b400321 100644 --- a/src/modules/simulado/simulado.repository.spec.ts +++ b/src/modules/simulado/simulado.repository.spec.ts @@ -109,8 +109,8 @@ describe('SimuladoRepository.answer (popula questoes.questao)', () => { }); }); -describe('SimuladoRepository.getById (popula questoes.questao, sem questoes)', () => { - it('popula categoria + questoes.questao e NÃO popula questoes', async () => { +describe('SimuladoRepository.getById (popula questoes.questao)', () => { + it('popula categoria + questoes.questao', async () => { const populateArgs: any[] = []; const exec = jest.fn().mockResolvedValue({ _id: 's1' }); const query: any = { exec }; @@ -127,10 +127,5 @@ describe('SimuladoRepository.getById (popula questoes.questao, sem questoes)', ( path: 'questoes.questao', populate: ['frente1', 'materia'], }); - expect( - populateArgs.some( - (a) => a && typeof a === 'object' && a.path === 'questoes', - ), - ).toBe(false); }); }); From d94e2dc08e5ed72cda4f034af119b8aad8b3721a Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 19:49:17 -0300 Subject: [PATCH 34/65] feat(questao): canInsertQuestion via reverse-lookup em Prova.questoes (+ProvaModel) Co-Authored-By: Claude Opus 4.8 --- .../questao/questao.repository.spec.ts | 32 +++++++++++++++++++ src/modules/questao/questao.repository.ts | 24 +++++++++----- 2 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 src/modules/questao/questao.repository.spec.ts diff --git a/src/modules/questao/questao.repository.spec.ts b/src/modules/questao/questao.repository.spec.ts new file mode 100644 index 0000000..d3d6384 --- /dev/null +++ b/src/modules/questao/questao.repository.spec.ts @@ -0,0 +1,32 @@ +import { QuestaoRepository } from './questao.repository'; + +describe('QuestaoRepository.canInsertQuestion (reverse-lookup em Prova.questoes)', () => { + function makeRepo(prova: any) { + const exec = jest.fn().mockResolvedValue(prova); + const populate = jest.fn().mockReturnValue({ exec }); + const findById = jest.fn().mockReturnValue({ populate }); + const questaoModel: any = {}; + const provaModel: any = { findById }; + return { repo: new QuestaoRepository(questaoModel, provaModel), findById, populate }; + } + + it('retorna false quando já existe entry com o mesmo numero+frente1', async () => { + const { repo } = makeRepo({ questoes: [{ numero: 5, questao: { frente1: 'f1' } }] }); + expect(await repo.canInsertQuestion('p1', 5, 'f1')).toBe(false); + }); + + it('retorna true quando o numero está livre', async () => { + const { repo } = makeRepo({ questoes: [{ numero: 6, questao: { frente1: 'f1' } }] }); + expect(await repo.canInsertQuestion('p1', 5, 'f1')).toBe(true); + }); + + it('retorna true quando o numero existe mas com outra frente1 (idiomática)', async () => { + const { repo } = makeRepo({ questoes: [{ numero: 5, questao: { frente1: 'fx' } }] }); + expect(await repo.canInsertQuestion('p1', 5, 'f1')).toBe(true); + }); + + it('retorna true quando a prova não existe', async () => { + const { repo } = makeRepo(null); + expect(await repo.canInsertQuestion('p1', 5, 'f1')).toBe(true); + }); +}); diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index 73e4cbc..4f8b53c 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -4,6 +4,7 @@ import { Model, Types } from 'mongoose'; import { BaseRepository } from 'src/shared/base/base.repository'; import { GetAllWhereInput } from 'src/shared/base/interfaces/get-all.input'; import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; +import { Prova } from '../prova/prova.schema'; import { Resposta } from '../historico/types/resposta'; import { UpdateClassificacaoDTOInput } from './dtos/update-classificacao.dto.input'; import { UpdateContentDTOInput } from './dtos/update-content.dto.input'; @@ -15,7 +16,10 @@ import { Questao } from './questao.schema'; @Injectable() export class QuestaoRepository extends BaseRepository { - constructor(@InjectModel(Questao.name) model: Model) { + constructor( + @InjectModel(Questao.name) model: Model, + @InjectModel(Prova.name) private readonly provaModel: Model, + ) { super(model); } @@ -222,13 +226,17 @@ export class QuestaoRepository extends BaseRepository { numero: number, frente1: string, ): Promise { - const questaoExistente = await this.model.findOne({ - prova: provaId, - numero, - frente1, - }); - - return !questaoExistente; // se já existe, não pode cadastrar → false + const prova = await this.provaModel + .findById(provaId) + .populate('questoes.questao') + .exec(); + if (!prova) return true; + const jaExiste = prova.questoes.some( + (qc: any) => + qc.numero === numero && + (qc.questao as any)?.frente1?.toString() === frente1, + ); + return !jaExiste; } async getTotalEntity() { From 9a47331868eb8d004af3138b60d54e52f467f3d5 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 19:53:24 -0300 Subject: [PATCH 35/65] =?UTF-8?q?feat(questao):=20delete=20via=20reverse-l?= =?UTF-8?q?ookup=20das=20provas=20contendo=20a=20quest=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/questao.repository.ts | 7 ++++ src/modules/questao/questao.service.spec.ts | 40 +++++++++++++++++++++ src/modules/questao/questao.service.ts | 7 ++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index 4f8b53c..d033774 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -221,6 +221,13 @@ export class QuestaoRepository extends BaseRepository { await this.model.deleteOne({ _id }); } + async findProvasContendo(questaoId: string): Promise { + return await this.provaModel + .find({ 'questoes.questao': questaoId }) + .populate('simulados') + .exec(); + } + async canInsertQuestion( provaId: string, numero: number, diff --git a/src/modules/questao/questao.service.spec.ts b/src/modules/questao/questao.service.spec.ts index 04c8460..2ea7222 100644 --- a/src/modules/questao/questao.service.spec.ts +++ b/src/modules/questao/questao.service.spec.ts @@ -90,3 +90,43 @@ describe('QuestaoService.create', () => { expect(mockFactory.createQuestion).not.toHaveBeenCalled(); }); }); + +describe('QuestaoService.delete (reverse-lookup provas contendo a questão)', () => { + it('remove a questão das provas/simulados que a contêm e deleta', async () => { + const question: any = { _id: 'q1', status: 'pending' }; + const prova: any = { _id: 'pr1', simulados: [{ _id: 's1' }] }; + const session = { + startTransaction: jest.fn(), + commitTransaction: jest.fn().mockResolvedValue(undefined), + abortTransaction: jest.fn().mockResolvedValue(undefined), + endSession: jest.fn(), + }; + const repository: any = { + getByIdToDelete: jest.fn().mockResolvedValue(question), + startSession: jest.fn().mockResolvedValue(session), + delete: jest.fn().mockResolvedValue(undefined), + findProvasContendo: jest.fn().mockResolvedValue([prova]), + }; + const simuladoService: any = { removeQuestionSimulados: jest.fn().mockResolvedValue(undefined) }; + const provaRepository: any = { removeQuestion: jest.fn().mockResolvedValue(undefined) }; + const { QuestaoService } = require('./questao.service'); + const service = new QuestaoService( + repository, // repository + {} as any, // provaService + provaRepository, // provaRepository + {} as any, // exameRepository + {} as any, // materiaRepository + {} as any, // frenteRepository + {} as any, // auditLogService + simuladoService, // simuladoService + {} as any, // provaFactory + ); + + await service.delete('q1'); + + expect(simuladoService.removeQuestionSimulados).toHaveBeenCalledWith([{ _id: 's1' }], question, session); + expect(provaRepository.removeQuestion).toHaveBeenCalledWith('pr1', question); + expect(repository.delete).toHaveBeenCalledWith('q1'); + expect(session.commitTransaction).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index 97cae35..25cf981 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -140,13 +140,14 @@ export class QuestaoService { const session = await this.repository.startSession(); session.startTransaction(); try { - if (question.prova) { + const provas = await this.repository.findProvasContendo(id); + for (const prova of provas) { await this.simuladoService.removeQuestionSimulados( - question.prova.simulados, + prova.simulados, question, session, ); - await this.provaRepository.removeQuestion(question.prova._id, question); + await this.provaRepository.removeQuestion(prova._id, question); } await this.repository.delete(id); await session.commitTransaction(); From cc94b93a7c19eec735dea8ba49be433f88420d51 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 19:57:59 -0300 Subject: [PATCH 36/65] feat(questao): updateStatus aprova/rejeita por reverse-lookup das provas Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/questao.service.spec.ts | 34 +++++++++++++++++++++ src/modules/questao/questao.service.ts | 13 +++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/modules/questao/questao.service.spec.ts b/src/modules/questao/questao.service.spec.ts index 2ea7222..2fe5f88 100644 --- a/src/modules/questao/questao.service.spec.ts +++ b/src/modules/questao/questao.service.spec.ts @@ -130,3 +130,37 @@ describe('QuestaoService.delete (reverse-lookup provas contendo a questão)', () expect(session.commitTransaction).toHaveBeenCalled(); }); }); + +describe('QuestaoService.updateStatus (reverse-lookup provas)', () => { + it('aprova a questão em cada prova que a contém', async () => { + const question: any = { _id: 'q1', status: 0 }; + const repository: any = { + getByIdToUpdate: jest.fn().mockResolvedValue(question), + UpdateStatus: jest.fn().mockResolvedValue(undefined), + findProvasContendo: jest.fn().mockResolvedValue([{ _id: 'pr1' }, { _id: 'pr2' }]), + }; + const provaService: any = { approvedQuestion: jest.fn().mockResolvedValue(undefined), refuseQuestion: jest.fn() }; + const auditLogService: any = { create: jest.fn().mockResolvedValue(undefined) }; + const { QuestaoService } = require('./questao.service'); + const service = new QuestaoService( + repository, provaService, {} as any, {} as any, {} as any, {} as any, auditLogService, {} as any, {} as any, + ); + const { Status } = require('./enums/status.enum'); + await service.updateStatus('q1', Status.Approved, 'user1'); + expect(provaService.approvedQuestion).toHaveBeenCalledWith('pr1', 'q1'); + expect(provaService.approvedQuestion).toHaveBeenCalledWith('pr2', 'q1'); + }); + + it('lança quando a questão não está em nenhuma prova', async () => { + const { Status } = require('./enums/status.enum'); + const repository: any = { + getByIdToUpdate: jest.fn().mockResolvedValue({ _id: 'q1', status: 0 }), + findProvasContendo: jest.fn().mockResolvedValue([]), + }; + const { QuestaoService } = require('./questao.service'); + const service = new QuestaoService( + repository, {} as any, {} as any, {} as any, {} as any, {} as any, {} as any, {} as any, {} as any, + ); + await expect(service.updateStatus('q1', Status.Approved, 'user1')).rejects.toBeTruthy(); + }); +}); diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index 25cf981..4b8e0a6 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -190,16 +190,21 @@ export class QuestaoService { HttpStatus.NOT_MODIFIED, ); } - if (!question.prova) { + const provas = await this.repository.findProvasContendo(id); + if (provas.length === 0) { throw new HttpException( - 'Para aprovar ou rejeitar, a prova não pode ser nula', + 'Para aprovar ou rejeitar, a questão precisa estar em ao menos uma prova', HttpStatus.BAD_REQUEST, ); } if (status === Status.Approved) { - await this.provaService.approvedQuestion(question.prova._id, id); + for (const prova of provas) { + await this.provaService.approvedQuestion(prova._id, id); + } } else if (question.status === Status.Approved) { - await this.provaService.refuseQuestion(question.prova._id, id); + for (const prova of provas) { + await this.provaService.refuseQuestion(prova._id, id); + } } await this.repository.UpdateStatus(id, status); await this.auditLogService.create({ From c5a4f09b30abee3919165f50b2eb1edf115e4fb1 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:03:19 -0300 Subject: [PATCH 37/65] feat(questao): getAll retorna provasContendo (reverse-lookup) + sort updatedAt Co-Authored-By: Claude Opus 4.8 --- .../questao/dtos/questao.all.dto.output.ts | 5 +-- src/modules/questao/questao.repository.ts | 37 +++++++++++++++++-- src/modules/questao/questao.service.spec.ts | 22 +++++++++++ src/modules/questao/questao.service.ts | 20 +++++----- 4 files changed, 66 insertions(+), 18 deletions(-) diff --git a/src/modules/questao/dtos/questao.all.dto.output.ts b/src/modules/questao/dtos/questao.all.dto.output.ts index 8e0b6af..7636723 100644 --- a/src/modules/questao/dtos/questao.all.dto.output.ts +++ b/src/modules/questao/dtos/questao.all.dto.output.ts @@ -7,7 +7,7 @@ export class QuestaoAllDTO { public _id?: string; @ApiProperty() - public prova: string; + public provasContendo: { provaId: string; provaNome: string; numero: number }[]; @ApiProperty() public enemArea: EnemArea; @@ -15,9 +15,6 @@ export class QuestaoAllDTO { @ApiProperty() public materia: string; - @ApiProperty() - public numero: number; - @ApiProperty() public status: Status; diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index d033774..65fa6cb 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -28,12 +28,12 @@ export class QuestaoRepository extends BaseRepository { limit, where, or, - sortColumn = 'numero', + sortColumn = 'updatedAt', sortOrder = 'asc', }: GetAllWhereInput): Promise> { const sortDirection: 1 | -1 = sortOrder === 'desc' ? -1 : 1; const sort: Record = { - [sortColumn ?? 'numero']: sortDirection, + [sortColumn ?? 'updatedAt']: sortDirection, }; const query = this.model @@ -41,7 +41,7 @@ export class QuestaoRepository extends BaseRepository { .sort(sort) .skip((page - 1) * limit) .limit(limit ?? Infinity) - .populate(['materia', 'prova']) + .populate(['materia']) .select('+alternativa'); const queryCount = this.model.where({ ...where }); @@ -228,6 +228,37 @@ export class QuestaoRepository extends BaseRepository { .exec(); } + async findQuestaoIdsByProva(provaId: string): Promise { + const prova = await this.provaModel.findById(provaId).select('questoes').exec(); + if (!prova) return []; + return prova.questoes.map((qc: any) => + ((qc.questao as any)?._id ?? qc.questao).toString(), + ); + } + + async findProvasContendoMany( + questaoIds: string[], + ): Promise> { + const provas = await this.provaModel + .find({ 'questoes.questao': { $in: questaoIds } }) + .select('nome questoes') + .exec(); + const map = new Map(); + for (const prova of provas) { + for (const qc of (prova as any).questoes) { + const qId = ((qc.questao as any)?._id ?? qc.questao).toString(); + if (!questaoIds.includes(qId)) continue; + if (!map.has(qId)) map.set(qId, []); + map.get(qId)!.push({ + provaId: (prova as any)._id.toString(), + provaNome: (prova as any).nome, + numero: qc.numero, + }); + } + } + return map; + } + async canInsertQuestion( provaId: string, numero: number, diff --git a/src/modules/questao/questao.service.spec.ts b/src/modules/questao/questao.service.spec.ts index 2fe5f88..13de042 100644 --- a/src/modules/questao/questao.service.spec.ts +++ b/src/modules/questao/questao.service.spec.ts @@ -164,3 +164,25 @@ describe('QuestaoService.updateStatus (reverse-lookup provas)', () => { await expect(service.updateStatus('q1', Status.Approved, 'user1')).rejects.toBeTruthy(); }); }); + +describe('QuestaoService.getAll (provasContendo)', () => { + it('monta provasContendo por questão via reverse-lookup', async () => { + const repository: any = { + getAll: jest.fn().mockResolvedValue({ + data: [{ _id: 'q1', enemArea: 'Mat', materia: { nome: 'M' }, status: 1, updatedAt: 'd' }], + page: 1, limit: 10, totalItems: 1, + }), + findProvasContendoMany: jest.fn().mockResolvedValue( + new Map([['q1', [{ provaId: 'pr1', provaNome: 'Prova 1', numero: 5 }]]]), + ), + }; + const { QuestaoService } = require('./questao.service'); + const service = new QuestaoService( + repository, {} as any, {} as any, {} as any, {} as any, {} as any, {} as any, {} as any, {} as any, + ); + const res = await service.getAll({ page: 1, limit: 10 }); + expect(res.data[0].provasContendo).toEqual([{ provaId: 'pr1', provaNome: 'Prova 1', numero: 5 }]); + expect((res.data[0] as any).prova).toBeUndefined(); + expect((res.data[0] as any).numero).toBeUndefined(); + }); +}); diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index 4b8e0a6..80999f4 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -79,8 +79,8 @@ export class QuestaoService { frente, prova, enemArea, - sortColumn = 'numero', - sortOrder = 'asc', + sortColumn = 'updatedAt' as QuestaoDTOInput['sortColumn'], + sortOrder = 'desc', }: QuestaoDTOInput): Promise> { const textConditions: any[] = text ? this.generateTextCombinations(text) @@ -94,10 +94,12 @@ export class QuestaoService { combineConditions.push(frenteorConditions); if (textConditions.length > 0) combineConditions.push(textConditions); - const where: Record = {}; + const where: Record = {}; if (status !== undefined) where['status'] = status; if (materia) where['materia'] = materia; - if (prova) where['prova'] = prova; + if (prova) { + where['_id'] = { $in: await this.repository.findQuestaoIdsByProva(prova) }; + } if (enemArea) where['enemArea'] = enemArea; const questoes = await this.repository.getAll({ @@ -109,12 +111,13 @@ export class QuestaoService { sortOrder, }); + const ids = questoes.data.map((q) => q._id.toString()); + const provasMap = await this.repository.findProvasContendoMany(ids); const questoesAll: QuestaoAllDTO[] = questoes.data.map((questao) => ({ _id: questao._id, - prova: questao.prova.nome, + provasContendo: provasMap.get(questao._id.toString()) ?? [], enemArea: questao.enemArea, materia: questao.materia?.nome, - numero: questao.numero, status: questao.status, updatedAt: questao.updatedAt, })); @@ -401,11 +404,6 @@ export class QuestaoService { textoAlternativaE: { $regex: text, $options: 'i' }, }); - const num = Number.parseInt(text); - if (!isNaN(num)) { - combinations.push({ numero: num }); - } - return combinations; } From d292ea157e2c9a5389b1cc0bd12f38b973547475 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:06:30 -0300 Subject: [PATCH 38/65] =?UTF-8?q?fix(questao):=20allowlist=20de=20sort=20i?= =?UTF-8?q?nclui=20updatedAt=20(default=20do=20banco=20de=20quest=C3=B5es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/dtos/questao.dto.input.ts | 12 ++++++------ src/modules/questao/questao.service.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/questao/dtos/questao.dto.input.ts b/src/modules/questao/dtos/questao.dto.input.ts index 43a3081..19d6c3f 100644 --- a/src/modules/questao/dtos/questao.dto.input.ts +++ b/src/modules/questao/dtos/questao.dto.input.ts @@ -8,7 +8,7 @@ import { import { GetAllDtoInput } from 'src/shared/dtos/get-all.dto.input'; import { Status } from '../enums/status.enum'; -export const QUESTAO_SORT_COLUMNS = ['numero'] as const; +export const QUESTAO_SORT_COLUMNS = ['numero', 'updatedAt'] as const; export type QuestaoSortColumn = (typeof QUESTAO_SORT_COLUMNS)[number]; export const SORT_ORDER = ['asc', 'desc'] as const; @@ -41,15 +41,15 @@ export class QuestaoDTOInput extends GetAllDtoInput { @IsOptional() text: string = ''; - /** Coluna para ordenação. Atualmente apenas "numero" é permitido. */ - @ApiProperty({ enum: QUESTAO_SORT_COLUMNS, default: 'numero' }) + /** Coluna para ordenação. Permitido: "numero" (legado) ou "updatedAt" (padrão). */ + @ApiProperty({ enum: QUESTAO_SORT_COLUMNS, default: 'updatedAt' }) @IsOptional() @IsIn(QUESTAO_SORT_COLUMNS) - sortColumn?: QuestaoSortColumn = 'numero'; + sortColumn?: QuestaoSortColumn = 'updatedAt'; /** Direção da ordenação: "asc" (menor para maior) ou "desc" (maior para menor). */ - @ApiProperty({ enum: SORT_ORDER, default: 'asc' }) + @ApiProperty({ enum: SORT_ORDER, default: 'desc' }) @IsOptional() @IsIn(SORT_ORDER) - sortOrder?: SortOrder = 'asc'; + sortOrder?: SortOrder = 'desc'; } diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index 80999f4..4eb901c 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -79,7 +79,7 @@ export class QuestaoService { frente, prova, enemArea, - sortColumn = 'updatedAt' as QuestaoDTOInput['sortColumn'], + sortColumn = 'updatedAt', sortOrder = 'desc', }: QuestaoDTOInput): Promise> { const textConditions: any[] = text From 13d228761021f380da67ee293e12c5f49bd32c57 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:09:20 -0300 Subject: [PATCH 39/65] =?UTF-8?q?fix(questao):=20sort=20do=20banco=20de=20?= =?UTF-8?q?quest=C3=B5es=20s=C3=B3=20por=20updatedAt=20(numero=20sera=20re?= =?UTF-8?q?movido)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/dtos/questao.dto.input.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/questao/dtos/questao.dto.input.ts b/src/modules/questao/dtos/questao.dto.input.ts index 19d6c3f..faff509 100644 --- a/src/modules/questao/dtos/questao.dto.input.ts +++ b/src/modules/questao/dtos/questao.dto.input.ts @@ -8,7 +8,7 @@ import { import { GetAllDtoInput } from 'src/shared/dtos/get-all.dto.input'; import { Status } from '../enums/status.enum'; -export const QUESTAO_SORT_COLUMNS = ['numero', 'updatedAt'] as const; +export const QUESTAO_SORT_COLUMNS = ['updatedAt'] as const; export type QuestaoSortColumn = (typeof QUESTAO_SORT_COLUMNS)[number]; export const SORT_ORDER = ['asc', 'desc'] as const; @@ -41,7 +41,7 @@ export class QuestaoDTOInput extends GetAllDtoInput { @IsOptional() text: string = ''; - /** Coluna para ordenação. Permitido: "numero" (legado) ou "updatedAt" (padrão). */ + /** Coluna para ordenação. Ordena por "updatedAt" (padrão). */ @ApiProperty({ enum: QUESTAO_SORT_COLUMNS, default: 'updatedAt' }) @IsOptional() @IsIn(QUESTAO_SORT_COLUMNS) From 23d8883d9b3280132c7ad009ab148f561c83c427 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:10:58 -0300 Subject: [PATCH 40/65] feat(questao): updateClassificacao via reverse-lookup + remove populate/escrita de prova/numero Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/questao.repository.ts | 18 +++++++++------- src/modules/questao/questao.service.spec.ts | 23 +++++++++++++++++++++ src/modules/questao/questao.service.ts | 4 ++-- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index 65fa6cb..39abc23 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -74,22 +74,26 @@ export class QuestaoRepository extends BaseRepository { return await this.model.findById(id).select('+alternativa'); } + async findProvaAtual(questaoId: string): Promise { + const prova = await this.provaModel + .findOne({ 'questoes.questao': questaoId }) + .select('_id') + .exec(); + return prova?._id?.toString(); + } + async getByIdToUpdate(id: string) { return await this.model .findById(id) .select('+alternativa') - .populate(['frente1', 'materia', 'prova']); + .populate(['frente1', 'materia']); } async getByIdToDelete(id: string) { return await this.model .findById(id) .select('+alternativa') - .populate(['frente1', 'materia', 'prova']) - .populate({ - path: 'prova', - populate: 'simulados', - }); + .populate(['frente1', 'materia']); } async getQuestaoByFiltro(filtro: object, quant: number): Promise { @@ -128,8 +132,6 @@ export class QuestaoRepository extends BaseRepository { classificacao: UpdateClassificacaoDTOInput, ) { const updateData: any = { - prova: classificacao.prova, - numero: classificacao.numero, enemArea: classificacao.enemArea, materia: classificacao.materia, frente1: classificacao.frente1, diff --git a/src/modules/questao/questao.service.spec.ts b/src/modules/questao/questao.service.spec.ts index 13de042..d079620 100644 --- a/src/modules/questao/questao.service.spec.ts +++ b/src/modules/questao/questao.service.spec.ts @@ -186,3 +186,26 @@ describe('QuestaoService.getAll (provasContendo)', () => { expect((res.data[0] as any).numero).toBeUndefined(); }); }); + +describe('QuestaoService.updateClassificacao (provaChanged via reverse-lookup)', () => { + it('detecta mudança de prova via findProvaAtual e dispara updateQuestion', async () => { + const questao: any = { _id: 'q1', enemArea: 'Mat', frente1: { _id: { toString: () => 'f1' } }, alternativa: 'A' }; + const repository: any = { + getByIdToUpdate: jest.fn().mockResolvedValue(questao), + findProvaAtual: jest.fn().mockResolvedValue('provaAntiga'), + updateClassificacao: jest.fn().mockResolvedValue(undefined), + }; + const provaRepository: any = { getById: jest.fn().mockResolvedValue({ _id: 'provaNova', categoria: {}, ano: 2020 }) }; + const provaFactory: any = { getFactory: jest.fn().mockReturnValue({ updateQuestion: jest.fn().mockResolvedValue(undefined) }) }; + const { QuestaoService } = require('./questao.service'); + const service = new QuestaoService( + repository, {} as any, provaRepository, {} as any, {} as any, {} as any, {} as any, {} as any, provaFactory, + ); + await service.updateClassificacao('q1', { + prova: 'provaNova', enemArea: 'Mat', frente1: 'f1', materia: 'm1', numero: 3, + } as any); + // prova mudou (provaAntiga -> provaNova) → chamou updateQuestion via factory + expect(provaFactory.getFactory).toHaveBeenCalled(); + expect(repository.updateClassificacao).toHaveBeenCalledWith('q1', expect.anything()); + }); +}); diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index 4eb901c..9e726d5 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -249,8 +249,8 @@ export class QuestaoService { throw new NotFoundException(`Questão com ID ${id} não encontrada.`); } - const provaChanged = - classificacao.prova !== questao.prova?._id?.toString(); + const provaAtual = await this.repository.findProvaAtual(id); + const provaChanged = classificacao.prova !== provaAtual; const enemAreaChanged = classificacao.enemArea !== questao.enemArea; const frente1Changed = classificacao.frente1 !== questao.frente1?._id?.toString(); From eb2cfc053e77350d933698b7215644fe9e91cf4d Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:17:41 -0300 Subject: [PATCH 41/65] feat(prova): factory updateQuestion deriva provaToLeaveId via reverse-lookup Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/factory/custom_prova_factory.spec.ts | 7 +++++++ src/modules/prova/factory/custom_prova_factory.ts | 4 +++- src/modules/prova/factory/enem_2010_2016_factory.ts | 4 +++- src/modules/prova/factory/enem_2017_plus_factory.spec.ts | 1 + src/modules/prova/factory/enem_2017_plus_factory.ts | 4 +++- 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/modules/prova/factory/custom_prova_factory.spec.ts b/src/modules/prova/factory/custom_prova_factory.spec.ts index 6a93e8b..bec0878 100644 --- a/src/modules/prova/factory/custom_prova_factory.spec.ts +++ b/src/modules/prova/factory/custom_prova_factory.spec.ts @@ -17,6 +17,7 @@ function makeFactory(overrides?: { getById?: jest.Mock; getProvaWithQuestion?: jest.Mock; getByIdToUpdate?: jest.Mock; + findProvaAtual?: jest.Mock; session?: ReturnType; }) { const session = overrides?.session ?? makeSession(); @@ -24,6 +25,8 @@ function makeFactory(overrides?: { startSession: jest.fn().mockResolvedValue(session), create: jest.fn().mockImplementation(async (q) => ({ ...q, _id: 'q-new' })), getByIdToUpdate: overrides?.getByIdToUpdate ?? jest.fn(), + findProvaAtual: + overrides?.findProvaAtual ?? jest.fn().mockResolvedValue(undefined), updateQuestion: jest.fn().mockResolvedValue(undefined), }; const provaRepository = { @@ -200,6 +203,7 @@ describe('CustomProvaFactory.updateQuestion', () => { makeFactory({ getByIdToUpdate: jest.fn().mockResolvedValue(questaoAtual), getById: jest.fn().mockResolvedValue({ _id: 'p1', simulados: [] }), + findProvaAtual: jest.fn().mockResolvedValue('p1'), }); await factory.updateQuestion({ _id: 'q1', prova: 'p1' } as any); @@ -222,6 +226,7 @@ describe('CustomProvaFactory.updateQuestion', () => { const { factory, simuladoService, provaRepository } = makeFactory({ getByIdToUpdate: jest.fn().mockResolvedValue(questaoAtual), getById, + findProvaAtual: jest.fn().mockResolvedValue('p-old'), }); await factory.updateQuestion({ _id: 'q1', prova: 'p-new' } as any); @@ -307,6 +312,7 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { const { factory, provaRepository, simuladoRepository } = makeFactory({ getById, getByIdToUpdate, + findProvaAtual: jest.fn().mockResolvedValue('p1'), }); await factory.updateQuestion({ @@ -336,6 +342,7 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { const { factory, provaRepository } = makeFactory({ getById, getByIdToUpdate, + findProvaAtual: jest.fn().mockResolvedValue('p1'), }); await factory.updateQuestion({ diff --git a/src/modules/prova/factory/custom_prova_factory.ts b/src/modules/prova/factory/custom_prova_factory.ts index 778cfe7..1ef7873 100644 --- a/src/modules/prova/factory/custom_prova_factory.ts +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -109,7 +109,9 @@ export class CustomProvaFactory implements IProvaFactory { public async updateQuestion(question: UpdateDTOInput): Promise { const questao = await this.questaoRepository.getByIdToUpdate(question._id); - const provaToLeaveId = questao.prova?._id.toString(); + const provaToLeaveId = await this.questaoRepository.findProvaAtual( + question._id, + ); const provaToEnter = await this.provaRepository.getById(question.prova); const changeProva = provaToLeaveId !== provaToEnter._id.toString(); diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index 8fb91de..f55eb01 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -230,7 +230,9 @@ export class Enem2010_2017Factory implements IProvaFactory { ); // Determina se houve mudança de prova ou de simulados - const provaToLeaveId = questao.prova?._id.toString(); + const provaToLeaveId = await this.questaoRepository.findProvaAtual( + question._id, + ); const provaToEnter = await this.provaRepository.getById(question.prova); const changeProva = provaToLeaveId !== provaToEnter._id.toString(); const changeSimulados = diff --git a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts index d55907e..87c6429 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts @@ -119,6 +119,7 @@ describe('Enem2017PlusFactory.updateQuestion — numero-sync (mudança pura de n prova: { _id: { toString: () => 'p1' } }, frente1: { _id: { toString: () => 'f0' } }, }), + findProvaAtual: jest.fn().mockResolvedValue('p1'), updateQuestion: jest.fn().mockResolvedValue(undefined), }; const provaRepository: any = { diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index c5e0309..2169215 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -204,7 +204,9 @@ export class Enem2017PlusFactory implements IProvaFactory { 5, ); - const provaToLeaveId = questao.prova?._id.toString(); + const provaToLeaveId = await this.questaoRepository.findProvaAtual( + question._id, + ); const provaToEnter = await this.provaRepository.getById(question.prova); const changeProva = provaToLeaveId !== provaToEnter._id.toString(); const changeSimulados = From 5c77f413ee9004627ef29d3775a5b92d9c687a74 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:30:48 -0300 Subject: [PATCH 42/65] refactor(prova): numero do subdoc vem do input (addQuestaoToContainer recebe numero explicito) Co-Authored-By: Claude Opus 4.8 --- .../prova/factory/custom_prova_factory.spec.ts | 5 ++++- .../prova/factory/custom_prova_factory.ts | 14 ++++++++++++-- .../prova/factory/enem_2010_2016_factory.ts | 14 ++++++++++++-- .../prova/factory/enem_2017_plus_factory.ts | 14 ++++++++++++-- .../helpers/question-container.helpers.spec.ts | 6 +++--- .../prova/helpers/question-container.helpers.ts | 3 ++- src/modules/prova/prova.repository.spec.ts | 16 ++++++++++------ src/modules/prova/prova.repository.ts | 4 ++-- src/modules/simulado/simulado.service.spec.ts | 16 ++++++++++------ src/modules/simulado/simulado.service.ts | 3 ++- 10 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/modules/prova/factory/custom_prova_factory.spec.ts b/src/modules/prova/factory/custom_prova_factory.spec.ts index bec0878..aecaae9 100644 --- a/src/modules/prova/factory/custom_prova_factory.spec.ts +++ b/src/modules/prova/factory/custom_prova_factory.spec.ts @@ -172,9 +172,10 @@ describe('CustomProvaFactory.createQuestion', () => { expect(simuladoService.addQuestionSimulados).toHaveBeenCalledWith( provaToEnter.simulados, result, + 1, session, ); - expect(provaRepository.addQuestion).toHaveBeenCalledWith('p1', result); + expect(provaRepository.addQuestion).toHaveBeenCalledWith('p1', result, 1); expect(session.commitTransaction).toHaveBeenCalledTimes(1); expect(session.abortTransaction).not.toHaveBeenCalled(); expect(session.endSession).toHaveBeenCalledTimes(1); @@ -243,11 +244,13 @@ describe('CustomProvaFactory.updateQuestion', () => { expect(simuladoService.addQuestionSimulados).toHaveBeenCalledWith( newProva.simulados, questaoAtual, + undefined, expect.anything(), ); expect(provaRepository.addQuestion).toHaveBeenCalledWith( 'p-new', questaoAtual, + undefined, ); }); }); diff --git a/src/modules/prova/factory/custom_prova_factory.ts b/src/modules/prova/factory/custom_prova_factory.ts index 1ef7873..fe0fefa 100644 --- a/src/modules/prova/factory/custom_prova_factory.ts +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -94,9 +94,14 @@ export class CustomProvaFactory implements IProvaFactory { await this.simuladoService.addQuestionSimulados( provaToEnter.simulados, result, + question.numero, session, ); - await this.provaRepository.addQuestion(question.prova, result); + await this.provaRepository.addQuestion( + question.prova, + result, + question.numero, + ); await session.commitTransaction(); return result; } catch (error) { @@ -129,9 +134,14 @@ export class CustomProvaFactory implements IProvaFactory { await this.simuladoService.addQuestionSimulados( provaToEnter.simulados, questao, + question.numero, session, ); - await this.provaRepository.addQuestion(question.prova, questao); + await this.provaRepository.addQuestion( + question.prova, + questao, + question.numero, + ); } await this.questaoRepository.updateQuestion(question); await session.commitTransaction(); diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index f55eb01..d3d553b 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -180,9 +180,14 @@ export class Enem2010_2017Factory implements IProvaFactory { await this.simuladoService.addQuestionSimulados( simuladosToEnter, result, + question.numero, session, ); - await this.provaRepository.addQuestion(question.prova, result); + await this.provaRepository.addQuestion( + question.prova, + result, + question.numero, + ); await session.commitTransaction(); return result; } catch (error) { @@ -297,9 +302,14 @@ export class Enem2010_2017Factory implements IProvaFactory { await this.simuladoService.addQuestionSimulados( simuladosActuallyEnter, questao, + question.numero, session, ); - await this.provaRepository.addQuestion(question.prova, questao); + await this.provaRepository.addQuestion( + question.prova, + questao, + question.numero, + ); } await this.questaoRepository.updateQuestion(question); await session.commitTransaction(); diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index 2169215..3d3d9f1 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -174,9 +174,14 @@ export class Enem2017PlusFactory implements IProvaFactory { await this.simuladoService.addQuestionSimulados( simuladosToEnter, result, + question.numero, session, ); - await this.provaRepository.addQuestion(question.prova, result); + await this.provaRepository.addQuestion( + question.prova, + result, + question.numero, + ); await session.commitTransaction(); return result; } catch (error) { @@ -274,9 +279,14 @@ export class Enem2017PlusFactory implements IProvaFactory { await this.simuladoService.addQuestionSimulados( simuladosActuallyEnter, questao, + question.numero, session, ); - await this.provaRepository.addQuestion(question.prova, questao); + await this.provaRepository.addQuestion( + question.prova, + questao, + question.numero, + ); } await this.questaoRepository.updateQuestion(question); await session.commitTransaction(); diff --git a/src/modules/prova/helpers/question-container.helpers.spec.ts b/src/modules/prova/helpers/question-container.helpers.spec.ts index af2ab0f..024f431 100644 --- a/src/modules/prova/helpers/question-container.helpers.spec.ts +++ b/src/modules/prova/helpers/question-container.helpers.spec.ts @@ -10,7 +10,7 @@ describe('question-container.helpers', () => { const container: any = { questoes: [] }; const questao: any = { _id: new Types.ObjectId(), numero: 7, status: 'approved' }; - addQuestaoToContainer(container, questao); + addQuestaoToContainer(container, questao, 7); expect(container.questoes).toHaveLength(1); expect(container.questoes[0].numero).toBe(7); @@ -24,8 +24,8 @@ describe('question-container.helpers', () => { const q1: any = { _id: new Types.ObjectId(), numero: 1 }; const q2: any = { _id: new Types.ObjectId(), numero: 1 }; - addQuestaoToContainer(container, q1); - addQuestaoToContainer(container, q2); + addQuestaoToContainer(container, q1, 1); + addQuestaoToContainer(container, q2, 1); expect(container.questoes).toHaveLength(2); expect(container.questoes.map((qc: any) => qc.numero)).toEqual([1, 1]); diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts index 5c583b2..7b02fbd 100644 --- a/src/modules/prova/helpers/question-container.helpers.ts +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -15,8 +15,9 @@ interface QuestaoContainer { export function addQuestaoToContainer( container: QuestaoContainer, questao: Questao, + numero: number, ): void { - container.questoes.push({ questao, numero: questao.numero }); + container.questoes.push({ questao, numero }); } /** diff --git a/src/modules/prova/prova.repository.spec.ts b/src/modules/prova/prova.repository.spec.ts index a591788..bbd7915 100644 --- a/src/modules/prova/prova.repository.spec.ts +++ b/src/modules/prova/prova.repository.spec.ts @@ -18,7 +18,7 @@ describe('ProvaRepository.addQuestion (single-write questoes)', () => { numero: 3, status: Status.Approved, }; - await repo.addQuestion('p1', questao); + await repo.addQuestion('p1', questao, 3); expect(prova.questoes).toHaveLength(1); expect(prova.questoes[0].numero).toBe(3); @@ -38,11 +38,15 @@ describe('ProvaRepository.addQuestion (single-write questoes)', () => { updateOne: jest.fn().mockResolvedValue({}), } as any); - await repo.addQuestion('p1', { - _id: new Types.ObjectId(), - numero: 1, - status: Status.Pending, - } as any); + await repo.addQuestion( + 'p1', + { + _id: new Types.ObjectId(), + numero: 1, + status: Status.Pending, + } as any, + 1, + ); expect(prova.totalQuestaoValidadas).toBe(0); }); diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index bb0f486..3e287de 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -41,9 +41,9 @@ export class ProvaRepository extends BaseRepository { }); } - public async addQuestion(id: string, question: Questao) { + public async addQuestion(id: string, question: Questao, numero: number) { const prova = await this.model.findById(id); - addQuestaoToContainer(prova, question); + addQuestaoToContainer(prova, question, numero); if (question.status === Status.Approved) { prova.totalQuestaoValidadas += 1; } diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index 0948f8f..ca004ad 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -236,7 +236,7 @@ describe('SimuladoService.addQuestionSimulados (single-write questoes)', () => { status: Status.Approved, }; - await service.addQuestionSimulados([sml], question); + await service.addQuestionSimulados([sml], question, 1); expect(sml.questoes).toHaveLength(1); expect(sml.bloqueado).toBe(false); @@ -254,11 +254,15 @@ describe('SimuladoService.addQuestionSimulados (single-write questoes)', () => { bloqueado: true, }; - await service.addQuestionSimulados([sml], { - _id: 'q1', - numero: 1, - status: Status.Approved, - } as any); + await service.addQuestionSimulados( + [sml], + { + _id: 'q1', + numero: 1, + status: Status.Approved, + } as any, + 1, + ); expect(sml.bloqueado).toBe(true); }); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 5969393..8eef887 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -125,12 +125,13 @@ export class SimuladoService { public async addQuestionSimulados( simulados: Simulado[], question: Questao, + numero: number, session?: ClientSession, ) { await Promise.all( simulados.map(async (sml) => { // Adiciona a nova questão (single-write em questoes). - addQuestaoToContainer(sml, question); + addQuestaoToContainer(sml, question, numero); // Verifica se o simulado atingiu a quantidade total de questões // (categoria livre / quantidadeTotalQuestao null sempre "atinge") From deb1d14fcaf774e1b6dab991be9ce2c7febb3d13 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:40:45 -0300 Subject: [PATCH 43/65] feat(simulado): ano via reverse-lookup + GetSimuladoById sem prova (cleanup) Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/dtos/questao-answer.dto.output.ts | 4 ---- src/modules/questao/questao.repository.ts | 8 ++++++++ src/modules/simulado/simulado.service.spec.ts | 9 +++++---- src/modules/simulado/simulado.service.ts | 9 +++------ 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/modules/questao/dtos/questao-answer.dto.output.ts b/src/modules/questao/dtos/questao-answer.dto.output.ts index 756205d..0200340 100644 --- a/src/modules/questao/dtos/questao-answer.dto.output.ts +++ b/src/modules/questao/dtos/questao-answer.dto.output.ts @@ -2,7 +2,6 @@ import { EnemArea } from '../enums/enem-area.enum'; import { Frente } from '../../frente/frente.schema'; import { Materia } from '../..//materia/materia.schema'; import { ApiProperty } from '@nestjs/swagger'; -import { Prova } from 'src/modules/prova/prova.schema'; export class QuestaoAnswerDTOOutput { @ApiProperty() @@ -25,7 +24,4 @@ export class QuestaoAnswerDTOOutput { @ApiProperty() public imageId: string; - - @ApiProperty() - public prova: Prova; } diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index 39abc23..2fbbcb4 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -82,6 +82,14 @@ export class QuestaoRepository extends BaseRepository { return prova?._id?.toString(); } + async findAnoByQuestao(questaoId: string): Promise { + const prova = await this.provaModel + .findOne({ 'questoes.questao': questaoId }) + .select('ano') + .exec(); + return prova?.ano; + } + async getByIdToUpdate(id: string) { return await this.model .findById(id) diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index ca004ad..187c91f 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -152,7 +152,6 @@ describe('SimuladoService.getToAnswer (gate de disponibilidade)', () => { materia: { _id: 'm1' }, numero: 7, imageId: 'img1', - prova: 'p1', }, ]); }); @@ -328,7 +327,7 @@ describe('SimuladoService.processAnswer (lê questoes)', () => { answer: jest.fn().mockResolvedValue(simulado), }; const questoesRepository: any = { - getById: jest.fn().mockResolvedValue({ prova: { ano: 2023 } }), + findAnoByQuestao: jest.fn().mockResolvedValue(2023), updateQuestionAnswered: jest.fn().mockResolvedValue(undefined), }; @@ -343,8 +342,10 @@ describe('SimuladoService.processAnswer (lê questoes)', () => { await service.processAnswer('hist1'); - // buscou o ano pela primeira questão de questoes - expect(questoesRepository.getById).toHaveBeenCalledWith(questao._id); + // buscou o ano pela primeira questão de questoes (reverse-lookup) + expect(questoesRepository.findAnoByQuestao).toHaveBeenCalledWith( + questao._id, + ); // completou com 1 resposta mapeada de questoes const payload = historicoRepository.completeProcessing.mock.calls[0][1]; expect(payload.ano).toBe(2023); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index 8eef887..c35ec0e 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -207,11 +207,9 @@ export class SimuladoService { historico.simulado.toString(); const simulado = await this.simuladoRepository.answer(simuladoId); - const ano = ( - await this.questoesRepository.getById( - simulado.questoes[0].questao._id, - ) - ).prova.ano; + const ano = await this.questoesRepository.findAnoByQuestao( + simulado.questoes[0].questao._id, + ); const respostasAproveitamento: RespostaAproveitamento[] = simulado.questoes.map((qc) => { @@ -281,7 +279,6 @@ export class SimuladoService { materia: qc.questao.materia, numero: qc.numero, imageId: qc.questao.imageId, - prova: qc.questao.prova, })), inicio: inicio, duracao: simulado.categoria.duracao, From 97854ee54da90edc9bed5ea3764f1988642105f2 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:46:25 -0300 Subject: [PATCH 44/65] refactor(questao): remove campos legado prova/numero do schema (+ gate numero-sync so por input) Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/factory/custom_prova_factory.spec.ts | 4 +--- src/modules/prova/factory/custom_prova_factory.ts | 2 +- src/modules/prova/factory/enem_2010_2016_factory.ts | 2 +- src/modules/prova/factory/enem_2017_plus_factory.ts | 2 +- src/modules/questao/questao.schema.ts | 9 --------- 5 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/modules/prova/factory/custom_prova_factory.spec.ts b/src/modules/prova/factory/custom_prova_factory.spec.ts index aecaae9..8431566 100644 --- a/src/modules/prova/factory/custom_prova_factory.spec.ts +++ b/src/modules/prova/factory/custom_prova_factory.spec.ts @@ -330,7 +330,7 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); }); - it('NÃO reconcilia quando o número não muda', async () => { + it('NÃO reconcilia quando o input não traz numero', async () => { const prova: any = { _id: 'p1', simulados: [], @@ -339,7 +339,6 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { const getById = jest.fn().mockResolvedValue(prova); const getByIdToUpdate = jest.fn().mockResolvedValue({ _id: 'qX', - numero: 5, prova: { _id: 'p1' }, }); const { factory, provaRepository } = makeFactory({ @@ -350,7 +349,6 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { await factory.updateQuestion({ _id: 'qX', - numero: 5, prova: 'p1', } as any); diff --git a/src/modules/prova/factory/custom_prova_factory.ts b/src/modules/prova/factory/custom_prova_factory.ts index fe0fefa..34a17b4 100644 --- a/src/modules/prova/factory/custom_prova_factory.ts +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -154,7 +154,7 @@ export class CustomProvaFactory implements IProvaFactory { // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place // na prova de destino e seus simulados que referenciam a questão. - if (question.numero != null && question.numero !== questao.numero) { + if (question.numero != null) { const provaAtual = await this.provaRepository.getById(question.prova); if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { await this.provaRepository.update(provaAtual); diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index d3d553b..b30a7f8 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -322,7 +322,7 @@ export class Enem2010_2017Factory implements IProvaFactory { // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place // na prova de destino e seus simulados que referenciam a questão. - if (question.numero != null && question.numero !== questao.numero) { + if (question.numero != null) { const provaAtual = await this.provaRepository.getById(question.prova); if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { await this.provaRepository.update(provaAtual); diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index 3d3d9f1..9421c81 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -299,7 +299,7 @@ export class Enem2017PlusFactory implements IProvaFactory { // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place // na prova de destino e seus simulados que referenciam a questão. - if (question.numero != null && question.numero !== questao.numero) { + if (question.numero != null) { const provaAtual = await this.provaRepository.getById(question.prova); if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { await this.provaRepository.update(provaAtual); diff --git a/src/modules/questao/questao.schema.ts b/src/modules/questao/questao.schema.ts index f924cd1..bde6d2c 100644 --- a/src/modules/questao/questao.schema.ts +++ b/src/modules/questao/questao.schema.ts @@ -3,7 +3,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Types } from 'mongoose'; import { Frente } from '../frente/frente.schema'; import { Materia } from '../materia/materia.schema'; -import { Prova } from '../prova/prova.schema'; import { Alternativa } from './enums/alternativa.enum'; import { EnemArea } from './enums/enem-area.enum'; import { Status } from './enums/status.enum'; @@ -31,10 +30,6 @@ export class Questao extends QuestaoReview { @ApiProperty() public materia: Materia; - @Prop({ required: false, default: null }) - @ApiProperty({ required: false, nullable: true }) - public numero: number | null; - @Prop({ required: false, default: '' }) @ApiProperty() public textoQuestao: string; @@ -106,10 +101,6 @@ export class Questao extends QuestaoReview { @ApiProperty() public status: Status; - @Prop({ ref: Prova.name, type: Types.ObjectId, required: false }) - @ApiProperty() - public prova?: Prova; - @Prop({ required: false, default: [], type: [String] }) @ApiProperty() public files: string[]; From 9410da22491878ff9e81ca9bae8f58129c33e6a9 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:51:52 -0300 Subject: [PATCH 45/65] chore(migration): script 0002 cleanup (unset antigo + rename questoesNovo->questoes + unset prova/numero) Co-Authored-By: Claude Opus 4.8 --- .../0002-cleanup-questoes-antigas/README.md | 71 +++++++++++++++++++ .../0002-cleanup-questoes-antigas/cleanup.sh | 35 +++++++++ .../0002-cleanup-questoes-antigas/rollback.md | 25 +++++++ .../0002-cleanup-questoes-antigas/validate.sh | 23 ++++++ 4 files changed, 154 insertions(+) create mode 100644 scripts/migrations/0002-cleanup-questoes-antigas/README.md create mode 100755 scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh create mode 100644 scripts/migrations/0002-cleanup-questoes-antigas/rollback.md create mode 100755 scripts/migrations/0002-cleanup-questoes-antigas/validate.sh diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/README.md b/scripts/migrations/0002-cleanup-questoes-antigas/README.md new file mode 100644 index 0000000..60cf0d7 --- /dev/null +++ b/scripts/migrations/0002-cleanup-questoes-antigas/README.md @@ -0,0 +1,71 @@ +# Migração 0002 — Cleanup Questões Antigas (Etapa 9, final) + +Cleanup final da Etapa 9. Derruba o array antigo de refs (`questoes`), renomeia +o subdoc `questoesNovo` → `questoes` em `Prova`/`Simulado`, e remove os campos +legado `prova`/`numero` de `Questao`. Também dropa os índices legados +(`prova_1`, `numero_1`) e cria os índices reversos finais +(`questoes.questao`) em `provas` e `simulados`. + +Diferente da 0001, esta migração **conecta ao Mongo** e opera in-place +(`$unset`/`$rename`/`updateMany`). + +> **IRREVERSÍVEL sem restaurar backup.** O `$rename`/`$unset` remove os dados +> antigos; não há como reverter por script. Rodar **somente** após smoke e +> monitoramento do deploy anterior (Card 05) estarem OK. Ver `rollback.md`. + +## Pré-requisitos + +`mongosh`, `bash`, e o Mongo Database Tools (`mongodump`/`mongorestore`). +`$MONGODB` = URI do banco alvo. + +## Procedimento (homol primeiro, depois prod) + +### 1. Backup obrigatório (restore safety) + +```bash +mongodump --uri="$MONGODB" \ + --collection=provas \ + --collection=simulados \ + --collection=questoes \ + --out=./backup-0002 +du -sh ./backup-0002 # confirmar tamanho não-zero +# subir ./backup-0002 para R2/S3 com retenção >=90 dias; anotar o caminho no PR +``` + +Backup destas 3 collections. É a **única** via de rollback (ver `rollback.md`). + +### 2. Rodar o cleanup + +```bash +MONGODB='...' bash cleanup.sh +``` + +Deve terminar imprimindo `cleanup 0002 OK`. O pré-check aborta se houver prova +com `questoesNovo` vazio mas `questoes` antigo populado (sinal de que a 0001 não +rodou) — nesse caso, rodar a migração 0001 antes. + +### 3. Validar + +```bash +MONGODB='...' bash validate.sh +``` + +Deve imprimir `validate 0002 OK` (exit 0). Se sair com código 1, NÃO seguir para +o deploy — investigar os logs (e, se preciso, restaurar do backup do passo 1). + +### 4. Deploy do código do Card 07a — imediatamente + +O código do Card 07a lê `questoes` no shape novo `[{questao, numero}]` que o +script acabou de criar. Fazer o deploy logo após o `validate.sh` passar, para +não deixar o banco no shape novo com código antigo em execução. + +### 5. Produção + +Repetir 1→4 em janela de manutenção, sem deploys/writes concorrentes. Backup +obrigatório antes. Se algo falhar → `rollback.md`. + +## Nota + +`$unset` roda **antes** do `$rename` para evitar colisão de campo (o destino +`questoes` precisa não existir antes de renomear `questoesNovo` sobre ele). +Rodar em homologação antes de produção. diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh b/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh new file mode 100755 index 0000000..c3dafcb --- /dev/null +++ b/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${MONGODB:?defina MONGODB (ex.: MONGODB='mongodb://localhost:27017/simulado')}" + +mongosh "$MONGODB" --quiet --eval ' + // Pré-check: aborta se alguma prova tem questoesNovo vazio mas questoes antigo populado + const ruins = db.provas.countDocuments({ + questoesNovo: { $in: [null, []] }, + "questoes.0": { $exists: true }, + }); + if (ruins > 0) { + throw new Error("ABORTA: " + ruins + " provas com questoesNovo vazio mas questoes antigo populado. Rode a migracao 0001 antes."); + } + + // Prova: derruba o array antigo e renomeia o subdoc + db.provas.updateMany({}, { $unset: { questoes: "" } }); + db.provas.updateMany({}, { $rename: { questoesNovo: "questoes" } }); + + // Simulado: idem + db.simulados.updateMany({}, { $unset: { questoes: "" } }); + db.simulados.updateMany({}, { $rename: { questoesNovo: "questoes" } }); + + // Questao: remove campos legado + db.questoes.updateMany({}, { $unset: { prova: "", numero: "" } }); + + // Índices legados + try { db.questoes.dropIndex("prova_1"); } catch (e) { print("prova_1 nao existe, ok"); } + try { db.questoes.dropIndex("numero_1"); } catch (e) { print("numero_1 nao existe, ok"); } + + // Índices reversos finais + db.provas.createIndex({ "questoes.questao": 1 }); + db.simulados.createIndex({ "questoes.questao": 1 }); + + print("cleanup 0002 OK"); +' diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/rollback.md b/scripts/migrations/0002-cleanup-questoes-antigas/rollback.md new file mode 100644 index 0000000..7010fdd --- /dev/null +++ b/scripts/migrations/0002-cleanup-questoes-antigas/rollback.md @@ -0,0 +1,25 @@ +# Rollback — Migração 0002 (Etapa 9, final) + +Esta migração é **IRREVERSÍVEL por script**. O `$rename`/`$unset` removeu os +dados antigos (`questoes` como array de refs, `Questao.prova`/`Questao.numero`). +Não há como reconstruí-los a partir do estado pós-migração. + +**A única via de rollback é restaurar o backup do passo 1 do `README.md`.** + +## Restaurar do backup + +```bash +mongorestore --uri="$MONGODB" --drop ./backup-0002 +``` + +Isso restaura as 3 collections (`provas`, `simulados`, `questoes`) ao estado +anterior ao `cleanup.sh` (`--drop` recria cada collection a partir do dump). + +Reverter o PR do Card 07a (código) junto, já que o código novo espera o shape +pós-migração. + +## Por que o backup é obrigatório + +Como o cleanup remove campos de forma destrutiva, sem o backup do passo 1 não é +possível voltar atrás. Por isso o backup (retenção >=90 dias) é pré-requisito e +deve ter o caminho anotado no PR antes de rodar `cleanup.sh`. diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh b/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh new file mode 100755 index 0000000..59876c5 --- /dev/null +++ b/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${MONGODB:?defina MONGODB}" + +mongosh "$MONGODB" --quiet --eval ' + const comLegado = db.questoes.countDocuments({ + $or: [ { prova: { $exists: true } }, { numero: { $exists: true } } ], + }); + if (comLegado > 0) throw new Error("FALHA: " + comLegado + " questoes ainda com prova/numero"); + + const comNovo = + db.provas.countDocuments({ questoesNovo: { $exists: true } }) + + db.simulados.countDocuments({ questoesNovo: { $exists: true } }); + if (comNovo > 0) throw new Error("FALHA: ainda existe questoesNovo em provas/simulados"); + + const provaShapeRuim = db.provas.countDocuments({ + "questoes.0": { $exists: true }, + "questoes.questao": { $exists: false }, + }); + if (provaShapeRuim > 0) throw new Error("FALHA: " + provaShapeRuim + " provas com questoes fora do shape subdoc"); + + print("validate 0002 OK"); +' From a60e0ac688ed36f50ee810ee081cccc968c5aeb5 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Mon, 3 Aug 2026 20:56:05 -0300 Subject: [PATCH 46/65] chore(migration): endurece script 0002 (pre-check simulados + validate + doc de falha) Co-Authored-By: Claude Opus 4.8 --- .../0002-cleanup-questoes-antigas/README.md | 12 ++++++++++++ .../0002-cleanup-questoes-antigas/cleanup.sh | 16 ++++++++++++---- .../0002-cleanup-questoes-antigas/validate.sh | 6 ++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/README.md b/scripts/migrations/0002-cleanup-questoes-antigas/README.md index 60cf0d7..41d3bea 100644 --- a/scripts/migrations/0002-cleanup-questoes-antigas/README.md +++ b/scripts/migrations/0002-cleanup-questoes-antigas/README.md @@ -64,6 +64,18 @@ não deixar o banco no shape novo com código antigo em execução. Repetir 1→4 em janela de manutenção, sem deploys/writes concorrentes. Backup obrigatório antes. Se algo falhar → `rollback.md`. +## Recuperação em caso de falha no meio + +- Este script deve rodar **uma única vez**, numa janela de manutenção **sem + escritas concorrentes**. +- Se ele **falhar no meio** (as operações são 5 `updateMany` separados, + **não-transacionais**), **NÃO re-execute**. Restaure o backup (ver + `rollback.md`) e recomece do zero — um estado parcial pode fazer o pré-check + abortar, ou uma re-execução dropar dados já renomeados. +- O pré-check **aborta** se detectar estado inconsistente (inclusive um banco + **já migrado**). Isso é **proteção**, não um erro a "forçar": se ele abortar, + investigue o estado do banco, não contorne o script. + ## Nota `$unset` roda **antes** do `$rename` para evitar colisão de campo (o destino diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh b/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh index c3dafcb..efdc37a 100755 --- a/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh +++ b/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh @@ -3,13 +3,21 @@ set -euo pipefail : "${MONGODB:?defina MONGODB (ex.: MONGODB='mongodb://localhost:27017/simulado')}" mongosh "$MONGODB" --quiet --eval ' - // Pré-check: aborta se alguma prova tem questoesNovo vazio mas questoes antigo populado - const ruins = db.provas.countDocuments({ + // Pré-check: aborta se alguma prova/simulado tem questoesNovo vazio mas questoes antigo populado + const provasRuins = db.provas.countDocuments({ questoesNovo: { $in: [null, []] }, "questoes.0": { $exists: true }, }); - if (ruins > 0) { - throw new Error("ABORTA: " + ruins + " provas com questoesNovo vazio mas questoes antigo populado. Rode a migracao 0001 antes."); + const simuladosRuins = db.simulados.countDocuments({ + questoesNovo: { $in: [null, []] }, + "questoes.0": { $exists: true }, + }); + if (provasRuins > 0 || simuladosRuins > 0) { + throw new Error( + "ABORTA: " + provasRuins + " provas e " + simuladosRuins + + " simulados com questoesNovo vazio mas questoes antigo populado. " + + "Se o banco JA foi migrado (questoes ja no shape subdoc, sem questoesNovo), NAO rode este script de novo — ele so deve rodar UMA vez. Caso contrario, rode a migracao 0001 antes." + ); } // Prova: derruba o array antigo e renomeia o subdoc diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh b/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh index 59876c5..d5320c7 100755 --- a/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh +++ b/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh @@ -19,5 +19,11 @@ mongosh "$MONGODB" --quiet --eval ' }); if (provaShapeRuim > 0) throw new Error("FALHA: " + provaShapeRuim + " provas com questoes fora do shape subdoc"); + const simuladoShapeRuim = db.simulados.countDocuments({ + "questoes.0": { $exists: true }, + "questoes.questao": { $exists: false }, + }); + if (simuladoShapeRuim > 0) throw new Error("FALHA: " + simuladoShapeRuim + " simulados com questoes fora do shape subdoc"); + print("validate 0002 OK"); ' From cb97972a1a3febc142a66ac6d922406ea715a136 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 14:38:48 -0300 Subject: [PATCH 47/65] feat(prova): helper syncNumeroNaProvaESimulados (numero-sync escopado) Co-Authored-By: Claude Opus 4.8 --- .../question-container.helpers.spec.ts | 56 +++++++++++++++++++ .../helpers/question-container.helpers.ts | 36 ++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/modules/prova/helpers/question-container.helpers.spec.ts b/src/modules/prova/helpers/question-container.helpers.spec.ts index 024f431..acd3783 100644 --- a/src/modules/prova/helpers/question-container.helpers.spec.ts +++ b/src/modules/prova/helpers/question-container.helpers.spec.ts @@ -64,6 +64,62 @@ describe('question-container.helpers', () => { }); }); +import { syncNumeroNaProvaESimulados } from './question-container.helpers'; + +describe('syncNumeroNaProvaESimulados', () => { + it('atualiza numero na prova e nos simulados que contem a questao', async () => { + const sml = { questoes: [{ questao: { _id: 'q1' }, numero: 5 }] }; + const prova = { + _id: 'p1', + questoes: [{ questao: { _id: 'q1' }, numero: 5 }], + simulados: [sml], + }; + const provaRepository = { + getById: jest.fn().mockResolvedValue(prova), + update: jest.fn().mockResolvedValue(undefined), + }; + const simuladoRepository = { update: jest.fn().mockResolvedValue(undefined) }; + + await syncNumeroNaProvaESimulados( + provaRepository as any, + simuladoRepository as any, + 'p1', + 'q1', + 9, + ); + + expect(prova.questoes[0].numero).toBe(9); + expect(sml.questoes[0].numero).toBe(9); + expect(provaRepository.update).toHaveBeenCalledWith(prova); + expect(simuladoRepository.update).toHaveBeenCalledWith(sml); + }); + + it('nao persiste quando numero nao muda', async () => { + const sml = { questoes: [{ questao: { _id: 'q1' }, numero: 9 }] }; + const prova = { + _id: 'p1', + questoes: [{ questao: { _id: 'q1' }, numero: 9 }], + simulados: [sml], + }; + const provaRepository = { + getById: jest.fn().mockResolvedValue(prova), + update: jest.fn(), + }; + const simuladoRepository = { update: jest.fn() }; + + await syncNumeroNaProvaESimulados( + provaRepository as any, + simuladoRepository as any, + 'p1', + 'q1', + 9, + ); + + expect(provaRepository.update).not.toHaveBeenCalled(); + expect(simuladoRepository.update).not.toHaveBeenCalled(); + }); +}); + describe('updateNumeroNoContainer', () => { it('atualiza o numero da entry correta e retorna true (questao objeto completo)', () => { const alvo = new Types.ObjectId(); diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts index 7b02fbd..355b8dc 100644 --- a/src/modules/prova/helpers/question-container.helpers.ts +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -42,6 +42,42 @@ export function removeQuestaoFromContainer( * questão exige reconciliar os containers (prova + simulados). Retorna se * alguma entry mudou (pra evitar persistência desnecessária). */ +interface ProvaRepositoryLike { + getById(id: string): Promise; + update(prova: QuestaoContainer): Promise; +} +interface SimuladoRepositoryLike { + update(simulado: QuestaoContainer): Promise; +} + +/** + * Sincroniza o `numero` de uma questão no container da prova e em cada simulado + * dela. Escopo intencional (Etapa 9): só a prova informada + seus simulados — + * número vive no relacionamento, não propaga a outras provas. Idempotente: + * `updateNumeroNoContainer` é no-op quando o número já está correto, então só + * persiste os containers que de fato mudaram. + */ +export async function syncNumeroNaProvaESimulados( + provaRepository: ProvaRepositoryLike, + simuladoRepository: SimuladoRepositoryLike, + provaId: string, + questaoId: Types.ObjectId | string, + numero: number, +): Promise { + const prova = await provaRepository.getById(provaId); + if (!prova) return; + if (updateNumeroNoContainer(prova, questaoId, numero)) { + await provaRepository.update(prova); + } + await Promise.all( + (prova.simulados ?? []).map(async (sml) => { + if (updateNumeroNoContainer(sml, questaoId, numero)) { + await simuladoRepository.update(sml); + } + }), + ); +} + export function updateNumeroNoContainer( container: QuestaoContainer, questaoId: Types.ObjectId | string, From 5163b01ecb7791764b7a07b57d2dcb63f1ee5f4f Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 14:39:28 -0300 Subject: [PATCH 48/65] feat(prova): ProvaService.syncNumero delegando ao helper Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.service.spec.ts | 31 +++++++++++++++++++++++++ src/modules/prova/prova.service.ts | 15 ++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts index aecf8bd..1585554 100644 --- a/src/modules/prova/prova.service.spec.ts +++ b/src/modules/prova/prova.service.spec.ts @@ -136,6 +136,37 @@ describe('ProvaService.getAllByCursinho', () => { }); }); +describe('ProvaService.syncNumero', () => { + it('delega para o helper usando repository e simuladoRepository do service', async () => { + const { ProvaService } = require('./prova.service'); + const sml = { questoes: [{ questao: { _id: 'q1' }, numero: 5 }] }; + const prova = { + _id: 'p1', + questoes: [{ questao: { _id: 'q1' }, numero: 5 }], + simulados: [sml], + }; + const repository: any = { + getById: jest.fn().mockResolvedValue(prova), + update: jest.fn().mockResolvedValue(undefined), + }; + const simuladoRepository: any = { update: jest.fn().mockResolvedValue(undefined) }; + const service = new ProvaService( + {} as any, + repository, + {} as any, + simuladoRepository, + {} as any, + ); + + await service.syncNumero('p1', 'q1', 9); + + expect(prova.questoes[0].numero).toBe(9); + expect(sml.questoes[0].numero).toBe(9); + expect(repository.update).toHaveBeenCalledWith(prova); + expect(simuladoRepository.update).toHaveBeenCalledWith(sml); + }); +}); + describe('ProvaService.refuseQuestion — recompute questoes', () => { it('exclui a questão recusada da contagem e bloqueia o simulado', async () => { const simulado: any = { diff --git a/src/modules/prova/prova.service.ts b/src/modules/prova/prova.service.ts index 7bac2a4..f13d7f9 100644 --- a/src/modules/prova/prova.service.ts +++ b/src/modules/prova/prova.service.ts @@ -13,6 +13,7 @@ import { ProvaRepository } from './prova.repository'; import { Prova } from './prova.schema'; import { UpdateProvaFilesDTO } from './dtos/update-files.dto.input'; import { atingiuQuantidade } from '../simulado/helpers/bloqueado'; +import { syncNumeroNaProvaESimulados } from './helpers/question-container.helpers'; @Injectable() export class ProvaService { @@ -58,6 +59,20 @@ export class ProvaService { return prova; } + public async syncNumero( + provaId: string, + questaoId: string, + numero: number, + ): Promise { + await syncNumeroNaProvaESimulados( + this.repository, + this.simuladoRepository, + provaId, + questaoId, + numero, + ); + } + private toProvaDTO(prova: Prova): GetProvaDTOOutout { return { _id: prova._id, From b48d6d10006ea34c9ffbcbb0df03861e1f10a28e Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 14:40:08 -0300 Subject: [PATCH 49/65] refactor(prova): factories usam syncNumeroNaProvaESimulados (dedup) Co-Authored-By: Claude Opus 4.8 --- .../prova/factory/custom_prova_factory.ts | 21 +++++++------------ .../prova/factory/enem_2017_plus_factory.ts | 21 +++++++------------ 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/modules/prova/factory/custom_prova_factory.ts b/src/modules/prova/factory/custom_prova_factory.ts index 34a17b4..cb79627 100644 --- a/src/modules/prova/factory/custom_prova_factory.ts +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -7,7 +7,7 @@ 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 { updateNumeroNoContainer } from '../helpers/question-container.helpers'; +import { syncNumeroNaProvaESimulados } from '../helpers/question-container.helpers'; import { ProvaRepository } from '../prova.repository'; import { Prova } from '../prova.schema'; import { IProvaFactory } from './types'; @@ -152,19 +152,14 @@ export class CustomProvaFactory implements IProvaFactory { session.endSession(); } - // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place - // na prova de destino e seus simulados que referenciam a questão. + // Numero-sync (cutover): numero vive no subdoc; reconcilia prova + simulados. if (question.numero != null) { - const provaAtual = await this.provaRepository.getById(question.prova); - if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { - await this.provaRepository.update(provaAtual); - } - await Promise.all( - provaAtual.simulados.map(async (sml) => { - if (updateNumeroNoContainer(sml, question._id, question.numero)) { - await this.simuladoRepository.update(sml); - } - }), + await syncNumeroNaProvaESimulados( + this.provaRepository, + this.simuladoRepository, + question.prova, + question._id, + question.numero, ); } } diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index 9421c81..521194c 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -11,7 +11,7 @@ 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 { CreateProvaDTOInput } from '../dtos/create.dto.input'; -import { updateNumeroNoContainer } from '../helpers/question-container.helpers'; +import { syncNumeroNaProvaESimulados } from '../helpers/question-container.helpers'; import { ProvaRepository } from '../prova.repository'; import { Prova } from '../prova.schema'; import { EnemService } from '../services/enem_service'; @@ -297,19 +297,14 @@ export class Enem2017PlusFactory implements IProvaFactory { session.endSession(); } - // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place - // na prova de destino e seus simulados que referenciam a questão. + // Numero-sync (cutover): numero vive no subdoc; reconcilia prova + simulados. if (question.numero != null) { - const provaAtual = await this.provaRepository.getById(question.prova); - if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { - await this.provaRepository.update(provaAtual); - } - await Promise.all( - provaAtual.simulados.map(async (sml) => { - if (updateNumeroNoContainer(sml, question._id, question.numero)) { - await this.simuladoRepository.update(sml); - } - }), + await syncNumeroNaProvaESimulados( + this.provaRepository, + this.simuladoRepository, + question.prova, + question._id, + question.numero, ); } } From fe819770c4df2f69138d2bf445cf81349331c9bc Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 14:41:04 -0300 Subject: [PATCH 50/65] fix(questao): numero-only sincroniza prova+simulados; remove provaChanged do updateClassificacao Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/questao.service.spec.ts | 54 +++++++++++++++++---- src/modules/questao/questao.service.ts | 13 +++-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/modules/questao/questao.service.spec.ts b/src/modules/questao/questao.service.spec.ts index d079620..90c3624 100644 --- a/src/modules/questao/questao.service.spec.ts +++ b/src/modules/questao/questao.service.spec.ts @@ -187,25 +187,59 @@ describe('QuestaoService.getAll (provasContendo)', () => { }); }); -describe('QuestaoService.updateClassificacao (provaChanged via reverse-lookup)', () => { - it('detecta mudança de prova via findProvaAtual e dispara updateQuestion', async () => { - const questao: any = { _id: 'q1', enemArea: 'Mat', frente1: { _id: { toString: () => 'f1' } }, alternativa: 'A' }; +describe('QuestaoService.updateClassificacao', () => { + const questao: any = { + _id: 'q1', + enemArea: 'Mat', + frente1: { _id: { toString: () => 'f1' } }, + alternativa: 'A', + }; + + it('numero-only: chama syncNumero e NAO a factory', async () => { const repository: any = { getByIdToUpdate: jest.fn().mockResolvedValue(questao), - findProvaAtual: jest.fn().mockResolvedValue('provaAntiga'), updateClassificacao: jest.fn().mockResolvedValue(undefined), }; - const provaRepository: any = { getById: jest.fn().mockResolvedValue({ _id: 'provaNova', categoria: {}, ano: 2020 }) }; - const provaFactory: any = { getFactory: jest.fn().mockReturnValue({ updateQuestion: jest.fn().mockResolvedValue(undefined) }) }; + const provaService: any = { syncNumero: jest.fn().mockResolvedValue(undefined) }; + const provaFactory: any = { getFactory: jest.fn() }; const { QuestaoService } = require('./questao.service'); const service = new QuestaoService( - repository, {} as any, provaRepository, {} as any, {} as any, {} as any, {} as any, {} as any, provaFactory, + repository, provaService, {} as any, {} as any, {} as any, + {} as any, {} as any, {} as any, provaFactory, ); + await service.updateClassificacao('q1', { - prova: 'provaNova', enemArea: 'Mat', frente1: 'f1', materia: 'm1', numero: 3, + prova: 'p1', enemArea: 'Mat', frente1: 'f1', materia: 'm1', numero: 7, } as any); - // prova mudou (provaAntiga -> provaNova) → chamou updateQuestion via factory - expect(provaFactory.getFactory).toHaveBeenCalled(); + + expect(provaService.syncNumero).toHaveBeenCalledWith('p1', 'q1', 7); + expect(provaFactory.getFactory).not.toHaveBeenCalled(); + expect(repository.updateClassificacao).toHaveBeenCalledWith('q1', expect.anything()); + }); + + it('enemArea mudou: dispara factory.updateQuestion e NAO syncNumero', async () => { + const repository: any = { + getByIdToUpdate: jest.fn().mockResolvedValue(questao), + updateClassificacao: jest.fn().mockResolvedValue(undefined), + }; + const provaService: any = { syncNumero: jest.fn() }; + const provaRepository: any = { + getById: jest.fn().mockResolvedValue({ _id: 'p1', categoria: {}, ano: 2020 }), + }; + const factory: any = { updateQuestion: jest.fn().mockResolvedValue(undefined) }; + const provaFactory: any = { getFactory: jest.fn().mockReturnValue(factory) }; + const { QuestaoService } = require('./questao.service'); + const service = new QuestaoService( + repository, provaService, provaRepository, {} as any, {} as any, + {} as any, {} as any, {} as any, provaFactory, + ); + + await service.updateClassificacao('q1', { + prova: 'p1', enemArea: 'Ling', frente1: 'f1', materia: 'm1', numero: 3, + } as any); + + expect(factory.updateQuestion).toHaveBeenCalled(); + expect(provaService.syncNumero).not.toHaveBeenCalled(); expect(repository.updateClassificacao).toHaveBeenCalledWith('q1', expect.anything()); }); }); diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index 9e726d5..eff43ed 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -249,14 +249,14 @@ export class QuestaoService { throw new NotFoundException(`Questão com ID ${id} não encontrada.`); } - const provaAtual = await this.repository.findProvaAtual(id); - const provaChanged = classificacao.prova !== provaAtual; const enemAreaChanged = classificacao.enemArea !== questao.enemArea; const frente1Changed = classificacao.frente1 !== questao.frente1?._id?.toString(); try { - if (provaChanged || enemAreaChanged || frente1Changed) { + // enemArea/frente1 podem mudar a membership de simulado (idiomáticas ENEM) + // → precisa da factory. A factory já sincroniza o numero no fim. + if (enemAreaChanged || frente1Changed) { const updateDto = new UpdateDTOInput(); updateDto._id = id; updateDto.prova = classificacao.prova; @@ -268,6 +268,13 @@ export class QuestaoService { updateDto.numero = classificacao.numero; updateDto.alternativa = questao.alternativa; await this.updateQuestion(updateDto); + } else if (classificacao.numero != null) { + // Só o numero mudou: sync escopado na prova editada + simulados dela. + await this.provaService.syncNumero( + classificacao.prova, + id, + classificacao.numero, + ); } await this.repository.updateClassificacao(id, classificacao); } catch (error: any) { From e21eebdd393de910f46adc6d68443d6f5e05c8cd Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 14:41:59 -0300 Subject: [PATCH 51/65] feat(questao): getById retorna provasContendo (reverse-lookup) Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/questao.service.spec.ts | 24 +++++++++++++++++++++ src/modules/questao/questao.service.ts | 10 +++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/modules/questao/questao.service.spec.ts b/src/modules/questao/questao.service.spec.ts index 90c3624..7ae95e6 100644 --- a/src/modules/questao/questao.service.spec.ts +++ b/src/modules/questao/questao.service.spec.ts @@ -187,6 +187,30 @@ describe('QuestaoService.getAll (provasContendo)', () => { }); }); +describe('QuestaoService.getById', () => { + it('anexa provasContendo ao detalhe da questao', async () => { + const doc: any = { _id: 'q1', toObject: () => ({ _id: 'q1', enemArea: 'Mat' }) }; + const repository: any = { + getById: jest.fn().mockResolvedValue(doc), + findProvasContendoMany: jest.fn().mockResolvedValue( + new Map([['q1', [{ provaId: 'p1', provaNome: 'Prova 1', numero: 4 }]]]), + ), + }; + const { QuestaoService } = require('./questao.service'); + const service = new QuestaoService( + repository, {} as any, {} as any, {} as any, {} as any, + {} as any, {} as any, {} as any, {} as any, + ); + + const res: any = await service.getById('q1'); + + expect(repository.findProvasContendoMany).toHaveBeenCalledWith(['q1']); + expect(res.provasContendo).toEqual([ + { provaId: 'p1', provaNome: 'Prova 1', numero: 4 }, + ]); + }); +}); + describe('QuestaoService.updateClassificacao', () => { const questao: any = { _id: 'q1', diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index eff43ed..f4d23f7 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -57,9 +57,15 @@ export class QuestaoService { ); } - public async getById(id: string): Promise { + public async getById(id: string): Promise { const questao = await this.repository.getById(id); - return questao; + if (!questao) return questao; + const map = await this.repository.findProvasContendoMany([id]); + const obj: any = (questao as any).toObject + ? (questao as any).toObject() + : questao; + obj.provasContendo = map.get(id.toString()) ?? []; + return obj; } public async canInsertQuestion( From 800af688d0159dcd38a16327d59eba3eb616e18e Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 14:54:30 -0300 Subject: [PATCH 52/65] refactor(prova): completa dedup numero-sync (enem_2010_2016) + fixes de review Co-Authored-By: Claude Opus 4.8 --- .../prova/factory/enem_2010_2016_factory.ts | 21 +++++++--------- .../question-container.helpers.spec.ts | 24 +++++++++++++++++-- .../helpers/question-container.helpers.ts | 12 +++++----- src/modules/prova/prova.service.spec.ts | 1 - 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index b30a7f8..56e056b 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -11,7 +11,7 @@ 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 { CreateProvaDTOInput } from '../dtos/create.dto.input'; -import { updateNumeroNoContainer } from '../helpers/question-container.helpers'; +import { syncNumeroNaProvaESimulados } from '../helpers/question-container.helpers'; import { ProvaRepository } from '../prova.repository'; import { Prova } from '../prova.schema'; import { EnemService } from '../services/enem_service'; @@ -320,19 +320,14 @@ export class Enem2010_2017Factory implements IProvaFactory { session.endSession(); } - // Numero-sync (cutover): numero vive no subdoc; se mudou, reconcilia in-place - // na prova de destino e seus simulados que referenciam a questão. + // Numero-sync (cutover): numero vive no subdoc; reconcilia prova + simulados. if (question.numero != null) { - const provaAtual = await this.provaRepository.getById(question.prova); - if (updateNumeroNoContainer(provaAtual, question._id, question.numero)) { - await this.provaRepository.update(provaAtual); - } - await Promise.all( - provaAtual.simulados.map(async (sml) => { - if (updateNumeroNoContainer(sml, question._id, question.numero)) { - await this.simuladoRepository.update(sml); - } - }), + await syncNumeroNaProvaESimulados( + this.provaRepository, + this.simuladoRepository, + question.prova, + question._id, + question.numero, ); } } diff --git a/src/modules/prova/helpers/question-container.helpers.spec.ts b/src/modules/prova/helpers/question-container.helpers.spec.ts index acd3783..340207b 100644 --- a/src/modules/prova/helpers/question-container.helpers.spec.ts +++ b/src/modules/prova/helpers/question-container.helpers.spec.ts @@ -2,6 +2,7 @@ import { Types } from 'mongoose'; import { addQuestaoToContainer, removeQuestaoFromContainer, + syncNumeroNaProvaESimulados, updateNumeroNoContainer, } from './question-container.helpers'; @@ -64,8 +65,6 @@ describe('question-container.helpers', () => { }); }); -import { syncNumeroNaProvaESimulados } from './question-container.helpers'; - describe('syncNumeroNaProvaESimulados', () => { it('atualiza numero na prova e nos simulados que contem a questao', async () => { const sml = { questoes: [{ questao: { _id: 'q1' }, numero: 5 }] }; @@ -118,6 +117,27 @@ describe('syncNumeroNaProvaESimulados', () => { expect(provaRepository.update).not.toHaveBeenCalled(); expect(simuladoRepository.update).not.toHaveBeenCalled(); }); + + it('retorna sem erro quando prova não existe', async () => { + const provaRepository = { + getById: jest.fn().mockResolvedValue(null), + update: jest.fn(), + }; + const simuladoRepository = { update: jest.fn() }; + + await expect( + syncNumeroNaProvaESimulados( + provaRepository as any, + simuladoRepository as any, + 'p-missing', + 'q1', + 9, + ), + ).resolves.toBeUndefined(); + + expect(provaRepository.update).not.toHaveBeenCalled(); + expect(simuladoRepository.update).not.toHaveBeenCalled(); + }); }); describe('updateNumeroNoContainer', () => { diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts index 355b8dc..509ce3f 100644 --- a/src/modules/prova/helpers/question-container.helpers.ts +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -36,12 +36,6 @@ export function removeQuestaoFromContainer( }); } -/** - * Atualiza in-place o `numero` das entries que referenciam `questaoId`. - * Usado no cutover: `numero` vive no subdoc, então editar o número de uma - * questão exige reconciliar os containers (prova + simulados). Retorna se - * alguma entry mudou (pra evitar persistência desnecessária). - */ interface ProvaRepositoryLike { getById(id: string): Promise; update(prova: QuestaoContainer): Promise; @@ -78,6 +72,12 @@ export async function syncNumeroNaProvaESimulados( ); } +/** + * Atualiza in-place o `numero` das entries que referenciam `questaoId`. + * Usado no cutover: `numero` vive no subdoc, então editar o número de uma + * questão exige reconciliar os containers (prova + simulados). Retorna se + * alguma entry mudou (pra evitar persistência desnecessária). + */ export function updateNumeroNoContainer( container: QuestaoContainer, questaoId: Types.ObjectId | string, diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts index 1585554..3885ca2 100644 --- a/src/modules/prova/prova.service.spec.ts +++ b/src/modules/prova/prova.service.spec.ts @@ -138,7 +138,6 @@ describe('ProvaService.getAllByCursinho', () => { describe('ProvaService.syncNumero', () => { it('delega para o helper usando repository e simuladoRepository do service', async () => { - const { ProvaService } = require('./prova.service'); const sml = { questoes: [{ questao: { _id: 'q1' }, numero: 5 }] }; const prova = { _id: 'p1', From e99fa3108c8cb59a85f0382c39e075c781b97147 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 17:44:57 -0300 Subject: [PATCH 53/65] feat(migration-0002): questao.prova -> provaBase (mantem numero) no cleanup Em vez de remover prova/numero da questao, o cleanup renomeia prova->provaBase e mantem numero: ancora da prova de origem, pra quem veio de prova unica preservar prova+numero. Questao sem prova segue sem provaBase/numero (modelo novo puro). validate.sh passa a checar: sem campo prova remanescente, numero sempre com provaBase, e rename preservado (#provaBase == #prova de origem). Inclui a tooling file-based da 0002 (indices.sh, rodado por ultimo contra o banco) e o .gitignore que protege os dumps reais de serem commitados. Co-Authored-By: Claude Opus 4.8 --- scripts/migrations/.gitignore | 5 + .../0002-cleanup-questoes-antigas/README.md | 100 +++++++++------- .../0002-cleanup-questoes-antigas/cleanup.sh | 95 +++++++++------ .../0002-cleanup-questoes-antigas/indices.sh | 27 +++++ .../0002-cleanup-questoes-antigas/rollback.md | 23 ++-- .../0002-cleanup-questoes-antigas/validate.sh | 108 +++++++++++++----- 6 files changed, 245 insertions(+), 113 deletions(-) create mode 100644 scripts/migrations/.gitignore create mode 100755 scripts/migrations/0002-cleanup-questoes-antigas/indices.sh diff --git a/scripts/migrations/.gitignore b/scripts/migrations/.gitignore new file mode 100644 index 0000000..3358121 --- /dev/null +++ b/scripts/migrations/.gitignore @@ -0,0 +1,5 @@ +# Dumps exportados/gerados pelas migrations (dados reais — nunca commitar). +# Só os scripts (*.sh) e docs (*.md) são versionados. +*.json +*.log +backup-*/ diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/README.md b/scripts/migrations/0002-cleanup-questoes-antigas/README.md index 41d3bea..9a701d4 100644 --- a/scripts/migrations/0002-cleanup-questoes-antigas/README.md +++ b/scripts/migrations/0002-cleanup-questoes-antigas/README.md @@ -1,25 +1,36 @@ # Migração 0002 — Cleanup Questões Antigas (Etapa 9, final) -Cleanup final da Etapa 9. Derruba o array antigo de refs (`questoes`), renomeia -o subdoc `questoesNovo` → `questoes` em `Prova`/`Simulado`, e remove os campos -legado `prova`/`numero` de `Questao`. Também dropa os índices legados -(`prova_1`, `numero_1`) e cria os índices reversos finais -(`questoes.questao`) em `provas` e `simulados`. +Cleanup final da Etapa 9. Dropa o array antigo de refs (`questoes`), renomeia o +subdoc `questoesNovo` → `questoes` em `Prova`/`Simulado`, e em `Questao` renomeia +`prova` → `provaBase` **mantendo** `numero` (âncora da prova de origem, pra quem +veio de uma prova única). Questão sem prova fica sem `provaBase`/`numero` — o +vínculo passa a viver só em `Prova.questoes[]`. -Diferente da 0001, esta migração **conecta ao Mongo** e opera in-place -(`$unset`/`$rename`/`updateMany`). +Como a **0001**, os scripts de transformação **NÃO conectam ao Mongo**: operam +sobre JSON exportado localmente e geram `*.out.json` para reimportar. -> **IRREVERSÍVEL sem restaurar backup.** O `$rename`/`$unset` remove os dados -> antigos; não há como reverter por script. Rodar **somente** após smoke e -> monitoramento do deploy anterior (Card 05) estarem OK. Ver `rollback.md`. +A **derrubada/criação de índices** é a única parte que precisa do banco (não sai +no `mongoexport` nem entra no `mongoimport`) e por isso fica num script final +separado, `indices.sh`, rodado por último. + +Os scripts: + +| Script | Conecta ao Mongo? | O que faz | +|--------|:-:|-----------| +| `cleanup.sh` | não | rename `questoesNovo`→`questoes`; `prova`→`provaBase` (mantém `numero`) → `*.out.json` | +| `validate.sh` | não | valida os `*.out.json` | +| `indices.sh` | **sim** | dropa índices legados e cria os índices reversos finais | ## Pré-requisitos -`mongosh`, `bash`, e o Mongo Database Tools (`mongodump`/`mongorestore`). -`$MONGODB` = URI do banco alvo. +`jq` (>=1.6), `bash`, o Mongo Database Tools (`mongodump`, `mongoexport`, +`mongoimport`) e `mongosh` (só para `indices.sh`). `$MONGODB` = URI do banco alvo. ## Procedimento (homol primeiro, depois prod) +Rodar **somente** após a 0001 ter sido aplicada (reimportada) e o deploy do +código anterior (Card 05) estar validado. + ### 1. Backup obrigatório (restore safety) ```bash @@ -32,52 +43,53 @@ du -sh ./backup-0002 # confirmar tamanho não-zero # subir ./backup-0002 para R2/S3 com retenção >=90 dias; anotar o caminho no PR ``` -Backup destas 3 collections. É a **única** via de rollback (ver `rollback.md`). +### 2. Exportar as collections (JSON array, Extended JSON relaxado = default) + +```bash +mongoexport --uri="$MONGODB" --collection=provas --jsonArray --out=provas.json +mongoexport --uri="$MONGODB" --collection=simulados --jsonArray --out=simulados.json +mongoexport --uri="$MONGODB" --collection=questoes --jsonArray --out=questoes.json +``` -### 2. Rodar o cleanup +### 3. Rodar o cleanup ```bash -MONGODB='...' bash cleanup.sh +bash cleanup.sh . # gera provas.out.json, simulados.out.json, questoes.out.json +bash validate.sh . # deve terminar com "✅ validate 0002 OK" (exit 0) ``` -Deve terminar imprimindo `cleanup 0002 OK`. O pré-check aborta se houver prova -com `questoesNovo` vazio mas `questoes` antigo populado (sinal de que a 0001 não -rodou) — nesse caso, rodar a migração 0001 antes. +O pré-check do `cleanup.sh` **aborta** se achar prova/simulado com `questoesNovo` +vazio mas `questoes` antigo populado (sinal de que a 0001 não rodou, ou de que o +dump já foi migrado). Se sair com código 1, NÃO reimportar — investigar os logs. -### 3. Validar +### 4. Reimportar (drop + import — backup já salvo) ```bash -MONGODB='...' bash validate.sh +mongosh "$MONGODB" --eval 'db.provas.drop(); db.simulados.drop(); db.questoes.drop();' +mongoimport --uri="$MONGODB" --collection=provas --jsonArray --file=provas.out.json +mongoimport --uri="$MONGODB" --collection=simulados --jsonArray --file=simulados.out.json +mongoimport --uri="$MONGODB" --collection=questoes --jsonArray --file=questoes.out.json ``` -Deve imprimir `validate 0002 OK` (exit 0). Se sair com código 1, NÃO seguir para -o deploy — investigar os logs (e, se preciso, restaurar do backup do passo 1). +### 5. Índices (conecta ao banco) — logo após reimportar -### 4. Deploy do código do Card 07a — imediatamente +```bash +MONGODB='...' bash indices.sh # deve terminar com "indices 0002 OK" +``` -O código do Card 07a lê `questoes` no shape novo `[{questao, numero}]` que o -script acabou de criar. Fazer o deploy logo após o `validate.sh` passar, para -não deixar o banco no shape novo com código antigo em execução. +### 6. Deploy do código do Card 07a — imediatamente -### 5. Produção +O código do Card 07a lê `questoes` no shape novo `[{questao, numero}]`. Fazer o +deploy logo após reimportar + `indices.sh`, para não deixar o banco no shape novo +com código antigo em execução. -Repetir 1→4 em janela de manutenção, sem deploys/writes concorrentes. Backup -obrigatório antes. Se algo falhar → `rollback.md`. +### 7. Produção -## Recuperação em caso de falha no meio - -- Este script deve rodar **uma única vez**, numa janela de manutenção **sem - escritas concorrentes**. -- Se ele **falhar no meio** (as operações são 5 `updateMany` separados, - **não-transacionais**), **NÃO re-execute**. Restaure o backup (ver - `rollback.md`) e recomece do zero — um estado parcial pode fazer o pré-check - abortar, ou uma re-execução dropar dados já renomeados. -- O pré-check **aborta** se detectar estado inconsistente (inclusive um banco - **já migrado**). Isso é **proteção**, não um erro a "forçar": se ele abortar, - investigue o estado do banco, não contorne o script. +Repetir 1→6 em janela de manutenção, sem deploys/writes concorrentes. Backup +obrigatório antes. Se algo falhar → `rollback.md`. -## Nota +## Idempotência -`$unset` roda **antes** do `$rename` para evitar colisão de campo (o destino -`questoes` precisa não existir antes de renomear `questoesNovo` sobre ele). -Rodar em homologação antes de produção. +`cleanup.sh` reescreve os `*.out.json` sem tocar nos `.json` de entrada — rodar +2x sobre o mesmo dump dá o mesmo resultado. `indices.sh` também é idempotente +(`dropIndex` ignora índice inexistente; `createIndex` é no-op se já existe). diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh b/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh index efdc37a..b497446 100755 --- a/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh +++ b/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh @@ -1,43 +1,70 @@ #!/usr/bin/env bash +# +# Migração 0002 — Cleanup Questões Antigas (Etapa 9, final). FILE-BASED. +# Espelha o modelo da 0001: NÃO conecta ao Mongo, opera sobre JSON exportado. +# +# Renomeia questoesNovo -> questoes (dropando o array antigo de refs) em +# provas/simulados. Em questoes, renomeia `prova` -> `provaBase` (âncora da prova +# de origem) e MANTÉM `numero` — assim a questão que veio de uma prova única +# preserva prova+número. Questão sem prova segue sem provaBase/numero (modelo +# novo puro; o vínculo passa a viver só em Prova.questoes[]). +# +# A derrubada/criação de índices (que EXIGE conexão ao banco — não dá pra fazer +# em arquivo) fica no script separado ./indices.sh, rodado por ÚLTIMO, depois de +# reimportar os .out.json. +# +# Uso: bash cleanup.sh [dir] (dir default = diretório atual) +# Entrada (mongoexport --jsonArray, Extended JSON): +# /provas.json /simulados.json /questoes.json +# Saída: +# /provas.out.json /simulados.out.json /questoes.out.json +# +# Idempotente sobre o mesmo input: reescreve os .out.json, sem tocar nos .json +# de entrada. Rodar 2x sobre o mesmo dump = mesmo resultado. set -euo pipefail -: "${MONGODB:?defina MONGODB (ex.: MONGODB='mongodb://localhost:27017/simulado')}" -mongosh "$MONGODB" --quiet --eval ' - // Pré-check: aborta se alguma prova/simulado tem questoesNovo vazio mas questoes antigo populado - const provasRuins = db.provas.countDocuments({ - questoesNovo: { $in: [null, []] }, - "questoes.0": { $exists: true }, - }); - const simuladosRuins = db.simulados.countDocuments({ - questoesNovo: { $in: [null, []] }, - "questoes.0": { $exists: true }, - }); - if (provasRuins > 0 || simuladosRuins > 0) { - throw new Error( - "ABORTA: " + provasRuins + " provas e " + simuladosRuins + - " simulados com questoesNovo vazio mas questoes antigo populado. " + - "Se o banco JA foi migrado (questoes ja no shape subdoc, sem questoesNovo), NAO rode este script de novo — ele so deve rodar UMA vez. Caso contrario, rode a migracao 0001 antes." - ); - } +DIR="${1:-.}" +PROVAS="$DIR/provas.json" +SIMULADOS="$DIR/simulados.json" +QUESTOES="$DIR/questoes.json" - // Prova: derruba o array antigo e renomeia o subdoc - db.provas.updateMany({}, { $unset: { questoes: "" } }); - db.provas.updateMany({}, { $rename: { questoesNovo: "questoes" } }); +for f in "$PROVAS" "$SIMULADOS" "$QUESTOES"; do + [ -f "$f" ] || { echo "❌ Arquivo não encontrado: $f" >&2; exit 1; } +done - // Simulado: idem - db.simulados.updateMany({}, { $unset: { questoes: "" } }); - db.simulados.updateMany({}, { $rename: { questoesNovo: "questoes" } }); +# ---- Pré-check: aborta se a 0001 não rodou (ou se o dump já foi migrado). +# Sinal de estado ruim: questoesNovo vazio/ausente MAS questoes antigo populado. +precheck() { + jq '[ .[] | select((.questoesNovo // [] | length) == 0 and ((.questoes // []) | length) > 0) ] | length' "$1" +} +provas_ruins=$(precheck "$PROVAS") +simulados_ruins=$(precheck "$SIMULADOS") +if [ "$provas_ruins" -ne 0 ] || [ "$simulados_ruins" -ne 0 ]; then + echo "❌ ABORTA: $provas_ruins provas e $simulados_ruins simulados com questoesNovo vazio mas questoes antigo populado." >&2 + echo " Rode a migração 0001 (e reimporte) antes — ou este dump não reflete o estado pós-0001." >&2 + echo " Se o banco JÁ foi migrado (questoes no shape novo, sem questoesNovo), NÃO rode de novo." >&2 + exit 1 +fi - // Questao: remove campos legado - db.questoes.updateMany({}, { $unset: { prova: "", numero: "" } }); +# ---- PROVAS: questoes := questoesNovo ; remove questoesNovo +jq 'map(.questoes = (.questoesNovo // []) | del(.questoesNovo))' "$PROVAS" > "$DIR/provas.out.json" +np=$(jq 'length' "$DIR/provas.out.json") +echo "[STEP 1] provas.out.json: $np docs (questoes := questoesNovo)" >&2 - // Índices legados - try { db.questoes.dropIndex("prova_1"); } catch (e) { print("prova_1 nao existe, ok"); } - try { db.questoes.dropIndex("numero_1"); } catch (e) { print("numero_1 nao existe, ok"); } +# ---- SIMULADOS: idem +jq 'map(.questoes = (.questoesNovo // []) | del(.questoesNovo))' "$SIMULADOS" > "$DIR/simulados.out.json" +ns=$(jq 'length' "$DIR/simulados.out.json") +echo "[STEP 2] simulados.out.json: $ns docs (questoes := questoesNovo)" >&2 - // Índices reversos finais - db.provas.createIndex({ "questoes.questao": 1 }); - db.simulados.createIndex({ "questoes.questao": 1 }); +# ---- QUESTOES: renomeia prova -> provaBase (mantém numero). Sem prova → sem +# provaBase/numero (modelo novo puro). +jq 'map(if (.prova != null) + then (.provaBase = .prova | del(.prova)) + else del(.prova, .numero) + end)' "$QUESTOES" > "$DIR/questoes.out.json" +nq=$(jq 'length' "$DIR/questoes.out.json") +comBase=$(jq '[ .[] | select(.provaBase != null) ] | length' "$DIR/questoes.out.json") +echo "[STEP 3] questoes.out.json: $nq docs ($comBase com provaBase; prova->provaBase, numero mantido)" >&2 - print("cleanup 0002 OK"); -' +echo "[DONE] provas.out.json, simulados.out.json, questoes.out.json gerados." >&2 +echo "cleanup 0002 OK" >&2 diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/indices.sh b/scripts/migrations/0002-cleanup-questoes-antigas/indices.sh new file mode 100755 index 0000000..48fb163 --- /dev/null +++ b/scripts/migrations/0002-cleanup-questoes-antigas/indices.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# +# Migração 0002 — passo FINAL: índices. +# ÚNICO passo que conecta ao Mongo — criar/derrubar índice não dá pra fazer em +# arquivo (não sai no mongoexport nem entra no mongoimport). +# +# Rodar por ÚLTIMO, DEPOIS de reimportar provas.out.json / simulados.out.json / +# questoes.out.json (gerados por cleanup.sh + validados por validate.sh). +# +# Uso: MONGODB='mongodb://localhost:27017/simulado' bash indices.sh +# +# Idempotente: dropIndex ignora índice inexistente; createIndex é no-op se o +# índice já existe com a mesma spec. +set -euo pipefail +: "${MONGODB:?defina MONGODB (ex.: MONGODB='mongodb://localhost:27017/simulado')}" + +mongosh "$MONGODB" --quiet --eval ' + // Índices legados (do modelo antigo Questao.prova / Questao.numero) + try { db.questoes.dropIndex("prova_1"); print("drop prova_1 ok"); } catch (e) { print("prova_1 nao existe, ok"); } + try { db.questoes.dropIndex("numero_1"); print("drop numero_1 ok"); } catch (e) { print("numero_1 nao existe, ok"); } + + // Índices reversos finais: lookup questao -> provas/simulados que a contêm + db.provas.createIndex({ "questoes.questao": 1 }); + db.simulados.createIndex({ "questoes.questao": 1 }); + + print("indices 0002 OK"); +' diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/rollback.md b/scripts/migrations/0002-cleanup-questoes-antigas/rollback.md index 7010fdd..4fbb36b 100644 --- a/scripts/migrations/0002-cleanup-questoes-antigas/rollback.md +++ b/scripts/migrations/0002-cleanup-questoes-antigas/rollback.md @@ -1,10 +1,12 @@ # Rollback — Migração 0002 (Etapa 9, final) -Esta migração é **IRREVERSÍVEL por script**. O `$rename`/`$unset` removeu os -dados antigos (`questoes` como array de refs, `Questao.prova`/`Questao.numero`). -Não há como reconstruí-los a partir do estado pós-migração. +A transformação da 0002 é **file-based**: `cleanup.sh` gera `*.out.json` a partir +do dump exportado, sem tocar no banco. O estado antigo do banco só muda no passo +de **reimportar** (`db.*.drop()` + `mongoimport`) e ao rodar `indices.sh`. -**A única via de rollback é restaurar o backup do passo 1 do `README.md`.** +Enquanto você **não reimportou**, não há o que reverter — é só descartar os +`*.out.json` e não reimportar. Depois do drop+import (ou do `indices.sh`), a via +de rollback é restaurar o backup. ## Restaurar do backup @@ -13,13 +15,18 @@ mongorestore --uri="$MONGODB" --drop ./backup-0002 ``` Isso restaura as 3 collections (`provas`, `simulados`, `questoes`) ao estado -anterior ao `cleanup.sh` (`--drop` recria cada collection a partir do dump). +anterior — inclusive `questoesNovo`, o `questoes` antigo de refs, e o campo +original `Questao.prova` (que a migração renomeia para `provaBase`) junto com +`Questao.numero`. O `--drop` recria cada collection a partir do dump, revertendo +também os índices. Reverter o PR do Card 07a (código) junto, já que o código novo espera o shape pós-migração. ## Por que o backup é obrigatório -Como o cleanup remove campos de forma destrutiva, sem o backup do passo 1 não é -possível voltar atrás. Por isso o backup (retenção >=90 dias) é pré-requisito e -deve ter o caminho anotado no PR antes de rodar `cleanup.sh`. +O dump exportado (`provas.json` etc.) preserva os dados antigos, mas o passo de +reimportar faz `drop` das collections. Se algo falhar depois do import (ou do +`indices.sh`), o backup do passo 1 do `README.md` é a forma garantida de voltar +atrás. Por isso o backup (retenção >=90 dias) é pré-requisito e deve ter o +caminho anotado no PR antes de rodar o cleanup. diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh b/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh index d5320c7..2a7129c 100755 --- a/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh +++ b/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh @@ -1,29 +1,83 @@ #!/usr/bin/env bash +# +# Validação da migração 0002 (file-based). Roda sobre input + output, sem Mongo. +# Uso: bash validate.sh [dir] (default = diretório atual) +# Requer: provas.json, simulados.json, questoes.json (input) e +# provas.out.json, simulados.out.json, questoes.out.json (output do cleanup.sh) +# Exit 0 = ok, 1 = falha. +# +# Nota: valida só o rename/limpeza (cleanup.sh). Os índices são criados por +# ./indices.sh (contra o banco) e não entram aqui. set -euo pipefail -: "${MONGODB:?defina MONGODB}" - -mongosh "$MONGODB" --quiet --eval ' - const comLegado = db.questoes.countDocuments({ - $or: [ { prova: { $exists: true } }, { numero: { $exists: true } } ], - }); - if (comLegado > 0) throw new Error("FALHA: " + comLegado + " questoes ainda com prova/numero"); - - const comNovo = - db.provas.countDocuments({ questoesNovo: { $exists: true } }) + - db.simulados.countDocuments({ questoesNovo: { $exists: true } }); - if (comNovo > 0) throw new Error("FALHA: ainda existe questoesNovo em provas/simulados"); - - const provaShapeRuim = db.provas.countDocuments({ - "questoes.0": { $exists: true }, - "questoes.questao": { $exists: false }, - }); - if (provaShapeRuim > 0) throw new Error("FALHA: " + provaShapeRuim + " provas com questoes fora do shape subdoc"); - - const simuladoShapeRuim = db.simulados.countDocuments({ - "questoes.0": { $exists: true }, - "questoes.questao": { $exists: false }, - }); - if (simuladoShapeRuim > 0) throw new Error("FALHA: " + simuladoShapeRuim + " simulados com questoes fora do shape subdoc"); - - print("validate 0002 OK"); -' + +DIR="${1:-.}" +PROVAS_IN="$DIR/provas.json"; SIMULADOS_IN="$DIR/simulados.json" +QUESTOES_IN="$DIR/questoes.json" +PROVAS_OUT="$DIR/provas.out.json"; SIMULADOS_OUT="$DIR/simulados.out.json" +QUESTOES_OUT="$DIR/questoes.out.json" + +for f in "$PROVAS_IN" "$SIMULADOS_IN" "$QUESTOES_IN" "$PROVAS_OUT" "$SIMULADOS_OUT" "$QUESTOES_OUT"; do + [ -f "$f" ] || { echo "❌ Arquivo não encontrado: $f" >&2; exit 1; } +done + +fail=0 + +# [1] Nenhum questoesNovo remanescente na saída +resid_p=$(jq '[ .[] | select(has("questoesNovo")) ] | length' "$PROVAS_OUT") +resid_s=$(jq '[ .[] | select(has("questoesNovo")) ] | length' "$SIMULADOS_OUT") +if [ "$resid_p" = "0" ] && [ "$resid_s" = "0" ]; then + echo "✓ [1] Nenhum questoesNovo remanescente (provas/simulados)" +else + echo "✗ [1] questoesNovo remanescente: $resid_p provas, $resid_s simulados" >&2; fail=1 +fi + +# [2] questoes no shape novo: todo elemento tem .questao +badshape_p=$(jq '[ .[] | .questoes[]? | select(has("questao") | not) ] | length' "$PROVAS_OUT") +badshape_s=$(jq '[ .[] | .questoes[]? | select(has("questao") | not) ] | length' "$SIMULADOS_OUT") +if [ "$badshape_p" = "0" ] && [ "$badshape_s" = "0" ]; then + echo "✓ [2] questoes no shape subdoc {questao, numero} (provas/simulados)" +else + echo "✗ [2] elementos fora do shape: $badshape_p provas, $badshape_s simulados" >&2; fail=1 +fi + +# [3] Rename preservou os dados: sum(out.questoes) == sum(in.questoesNovo) +exp_p=$(jq '[ .[] | (.questoesNovo // []) | length ] | add // 0' "$PROVAS_IN") +got_p=$(jq '[ .[] | (.questoes // []) | length ] | add // 0' "$PROVAS_OUT") +exp_s=$(jq '[ .[] | (.questoesNovo // []) | length ] | add // 0' "$SIMULADOS_IN") +got_s=$(jq '[ .[] | (.questoes // []) | length ] | add // 0' "$SIMULADOS_OUT") +if [ "$exp_p" = "$got_p" ] && [ "$exp_s" = "$got_s" ]; then + echo "✓ [3] Dados preservados: provas $got_p entries, simulados $got_s entries" +else + echo "✗ [3] Divergência: provas $got_p (esperado $exp_p), simulados $got_s (esperado $exp_s)" >&2; fail=1 +fi + +# [4] questoes.out.json: nenhum campo `prova` remanescente (foi renomeado) +resid_prova=$(jq '[ .[] | select(has("prova")) ] | length' "$QUESTOES_OUT") +if [ "$resid_prova" = "0" ]; then + echo "✓ [4] Nenhuma questao com campo legado prova remanescente" +else + echo "✗ [4] $resid_prova questoes ainda com campo prova" >&2; fail=1 +fi + +# [5] numero só existe acompanhado de provaBase (sem provaBase => sem numero) +num_orfao=$(jq '[ .[] | select((.numero != null) and (.provaBase == null)) ] | length' "$QUESTOES_OUT") +if [ "$num_orfao" = "0" ]; then + echo "✓ [5] Nenhum numero orfao (todo numero tem provaBase)" +else + echo "✗ [5] $num_orfao questoes com numero sem provaBase" >&2; fail=1 +fi + +# [6] Rename preservou o vínculo: #provaBase(out) == #prova não-nula(in) +exp_base=$(jq '[ .[] | select(.prova != null) ] | length' "$QUESTOES_IN") +got_base=$(jq '[ .[] | select(.provaBase != null) ] | length' "$QUESTOES_OUT") +if [ "$exp_base" = "$got_base" ]; then + echo "✓ [6] provaBase preservado: $got_base questoes (prova->provaBase)" +else + echo "✗ [6] Divergência provaBase: $got_base (esperado $exp_base)" >&2; fail=1 +fi + +if [ "$fail" -eq 0 ]; then + echo "✅ validate 0002 OK"; exit 0 +else + echo "❌ Validação falhou"; exit 1 +fi From b88586e32733047f265de98688b65db5b16d7c29 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 18:00:05 -0300 Subject: [PATCH 54/65] fix(migration-0002): numero sai da questao (so provaBase fica) Corrige a decisao anterior: numero NAO fica na questao. cleanup renomeia prova->provaBase (ponteiro pra prova de origem) e remove numero. O numero vive so no relacionamento (Prova.questoes[].numero); o dash casa provaBase com provasContendo pra obter prova+numero. validate.sh: numero removido de todas. Verificado em 2640 questoes reais (6/6 checks). Co-Authored-By: Claude Opus 4.8 --- .../0002-cleanup-questoes-antigas/README.md | 9 +++++---- .../0002-cleanup-questoes-antigas/cleanup.sh | 19 ++++++++----------- .../0002-cleanup-questoes-antigas/validate.sh | 10 +++++----- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/README.md b/scripts/migrations/0002-cleanup-questoes-antigas/README.md index 9a701d4..d7ee8ab 100644 --- a/scripts/migrations/0002-cleanup-questoes-antigas/README.md +++ b/scripts/migrations/0002-cleanup-questoes-antigas/README.md @@ -2,9 +2,10 @@ Cleanup final da Etapa 9. Dropa o array antigo de refs (`questoes`), renomeia o subdoc `questoesNovo` → `questoes` em `Prova`/`Simulado`, e em `Questao` renomeia -`prova` → `provaBase` **mantendo** `numero` (âncora da prova de origem, pra quem -veio de uma prova única). Questão sem prova fica sem `provaBase`/`numero` — o -vínculo passa a viver só em `Prova.questoes[]`. +`prova` → `provaBase` (ponteiro pra prova de origem) e **remove** `numero` — o +número passa a viver só no relacionamento (`Prova.questoes[].numero`). No dash, +casa-se `provaBase` com `provasContendo` pra obter prova+número. Questão sem prova +fica sem `provaBase`. Como a **0001**, os scripts de transformação **NÃO conectam ao Mongo**: operam sobre JSON exportado localmente e geram `*.out.json` para reimportar. @@ -17,7 +18,7 @@ Os scripts: | Script | Conecta ao Mongo? | O que faz | |--------|:-:|-----------| -| `cleanup.sh` | não | rename `questoesNovo`→`questoes`; `prova`→`provaBase` (mantém `numero`) → `*.out.json` | +| `cleanup.sh` | não | rename `questoesNovo`→`questoes`; `prova`→`provaBase` + remove `numero` → `*.out.json` | | `validate.sh` | não | valida os `*.out.json` | | `indices.sh` | **sim** | dropa índices legados e cria os índices reversos finais | diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh b/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh index b497446..bf994c3 100755 --- a/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh +++ b/scripts/migrations/0002-cleanup-questoes-antigas/cleanup.sh @@ -4,10 +4,10 @@ # Espelha o modelo da 0001: NÃO conecta ao Mongo, opera sobre JSON exportado. # # Renomeia questoesNovo -> questoes (dropando o array antigo de refs) em -# provas/simulados. Em questoes, renomeia `prova` -> `provaBase` (âncora da prova -# de origem) e MANTÉM `numero` — assim a questão que veio de uma prova única -# preserva prova+número. Questão sem prova segue sem provaBase/numero (modelo -# novo puro; o vínculo passa a viver só em Prova.questoes[]). +# provas/simulados. Em questoes, renomeia `prova` -> `provaBase` (ponteiro pra +# prova de origem) e REMOVE `numero` — número vive só no relacionamento +# (Prova.questoes[].numero). No dash, casa-se `provaBase` com `provasContendo` +# pra obter prova+número. Questão sem prova fica sem provaBase. # # A derrubada/criação de índices (que EXIGE conexão ao banco — não dá pra fazer # em arquivo) fica no script separado ./indices.sh, rodado por ÚLTIMO, depois de @@ -56,15 +56,12 @@ jq 'map(.questoes = (.questoesNovo // []) | del(.questoesNovo))' "$SIMULADOS" > ns=$(jq 'length' "$DIR/simulados.out.json") echo "[STEP 2] simulados.out.json: $ns docs (questoes := questoesNovo)" >&2 -# ---- QUESTOES: renomeia prova -> provaBase (mantém numero). Sem prova → sem -# provaBase/numero (modelo novo puro). -jq 'map(if (.prova != null) - then (.provaBase = .prova | del(.prova)) - else del(.prova, .numero) - end)' "$QUESTOES" > "$DIR/questoes.out.json" +# ---- QUESTOES: renomeia prova -> provaBase (ponteiro) e REMOVE numero. +# numero vive só no relacionamento (Prova.questoes[].numero). +jq 'map((if (.prova != null) then .provaBase = .prova else . end) | del(.prova, .numero))' "$QUESTOES" > "$DIR/questoes.out.json" nq=$(jq 'length' "$DIR/questoes.out.json") comBase=$(jq '[ .[] | select(.provaBase != null) ] | length' "$DIR/questoes.out.json") -echo "[STEP 3] questoes.out.json: $nq docs ($comBase com provaBase; prova->provaBase, numero mantido)" >&2 +echo "[STEP 3] questoes.out.json: $nq docs ($comBase com provaBase; prova->provaBase, numero removido)" >&2 echo "[DONE] provas.out.json, simulados.out.json, questoes.out.json gerados." >&2 echo "cleanup 0002 OK" >&2 diff --git a/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh b/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh index 2a7129c..c571481 100755 --- a/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh +++ b/scripts/migrations/0002-cleanup-questoes-antigas/validate.sh @@ -59,12 +59,12 @@ else echo "✗ [4] $resid_prova questoes ainda com campo prova" >&2; fail=1 fi -# [5] numero só existe acompanhado de provaBase (sem provaBase => sem numero) -num_orfao=$(jq '[ .[] | select((.numero != null) and (.provaBase == null)) ] | length' "$QUESTOES_OUT") -if [ "$num_orfao" = "0" ]; then - echo "✓ [5] Nenhum numero orfao (todo numero tem provaBase)" +# [5] numero saiu de todas as questoes (vive só no relacionamento) +resid_num=$(jq '[ .[] | select(has("numero")) ] | length' "$QUESTOES_OUT") +if [ "$resid_num" = "0" ]; then + echo "✓ [5] Nenhuma questao com numero (numero vive no relacionamento)" else - echo "✗ [5] $num_orfao questoes com numero sem provaBase" >&2; fail=1 + echo "✗ [5] $resid_num questoes ainda com numero" >&2; fail=1 fi # [6] Rename preservou o vínculo: #provaBase(out) == #prova não-nula(in) From c40be3e3c81ca49e4948f0213a79b5c2fde16fcb Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 18:03:59 -0300 Subject: [PATCH 55/65] =?UTF-8?q?feat(questao):=20exp=C3=B5e=20provaBase?= =?UTF-8?q?=20(ancora=20da=20prova=20de=20origem)=20no=20schema=20+=20getA?= =?UTF-8?q?ll/getById?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona provaBase (String, id hex, não populado) ao schema Questao e o expõe em getAll (QuestaoAllDTO) e getById. Numero segue fora da questao — o dash casa provaBase com provasContendo pra obter prova+numero. Testes cobrem provaBase em getAll e getById. Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/dtos/questao.all.dto.output.ts | 3 +++ src/modules/questao/questao.schema.ts | 7 +++++++ src/modules/questao/questao.service.spec.ts | 6 ++++-- src/modules/questao/questao.service.ts | 1 + 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/modules/questao/dtos/questao.all.dto.output.ts b/src/modules/questao/dtos/questao.all.dto.output.ts index 7636723..6b8795b 100644 --- a/src/modules/questao/dtos/questao.all.dto.output.ts +++ b/src/modules/questao/dtos/questao.all.dto.output.ts @@ -9,6 +9,9 @@ export class QuestaoAllDTO { @ApiProperty() public provasContendo: { provaId: string; provaNome: string; numero: number }[]; + @ApiProperty({ required: false, nullable: true }) + public provaBase?: string | null; + @ApiProperty() public enemArea: EnemArea; diff --git a/src/modules/questao/questao.schema.ts b/src/modules/questao/questao.schema.ts index bde6d2c..9331405 100644 --- a/src/modules/questao/questao.schema.ts +++ b/src/modules/questao/questao.schema.ts @@ -30,6 +30,13 @@ export class Questao extends QuestaoReview { @ApiProperty() public materia: Materia; + // Âncora da prova de origem (renomeada de `prova` na migração 0002). String + // (id hex), não populada: serve pra casar com `provasContendo` no dash e obter + // prova+número. O número não vive mais na questão — vem do relacionamento. + @Prop({ type: String, required: false, default: null }) + @ApiProperty({ required: false, nullable: true }) + public provaBase?: string | null; + @Prop({ required: false, default: '' }) @ApiProperty() public textoQuestao: string; diff --git a/src/modules/questao/questao.service.spec.ts b/src/modules/questao/questao.service.spec.ts index 7ae95e6..11cffb3 100644 --- a/src/modules/questao/questao.service.spec.ts +++ b/src/modules/questao/questao.service.spec.ts @@ -169,7 +169,7 @@ describe('QuestaoService.getAll (provasContendo)', () => { it('monta provasContendo por questão via reverse-lookup', async () => { const repository: any = { getAll: jest.fn().mockResolvedValue({ - data: [{ _id: 'q1', enemArea: 'Mat', materia: { nome: 'M' }, status: 1, updatedAt: 'd' }], + data: [{ _id: 'q1', provaBase: 'pr1', enemArea: 'Mat', materia: { nome: 'M' }, status: 1, updatedAt: 'd' }], page: 1, limit: 10, totalItems: 1, }), findProvasContendoMany: jest.fn().mockResolvedValue( @@ -182,6 +182,7 @@ describe('QuestaoService.getAll (provasContendo)', () => { ); const res = await service.getAll({ page: 1, limit: 10 }); expect(res.data[0].provasContendo).toEqual([{ provaId: 'pr1', provaNome: 'Prova 1', numero: 5 }]); + expect(res.data[0].provaBase).toBe('pr1'); expect((res.data[0] as any).prova).toBeUndefined(); expect((res.data[0] as any).numero).toBeUndefined(); }); @@ -189,7 +190,7 @@ describe('QuestaoService.getAll (provasContendo)', () => { describe('QuestaoService.getById', () => { it('anexa provasContendo ao detalhe da questao', async () => { - const doc: any = { _id: 'q1', toObject: () => ({ _id: 'q1', enemArea: 'Mat' }) }; + const doc: any = { _id: 'q1', toObject: () => ({ _id: 'q1', enemArea: 'Mat', provaBase: 'p1' }) }; const repository: any = { getById: jest.fn().mockResolvedValue(doc), findProvasContendoMany: jest.fn().mockResolvedValue( @@ -208,6 +209,7 @@ describe('QuestaoService.getById', () => { expect(res.provasContendo).toEqual([ { provaId: 'p1', provaNome: 'Prova 1', numero: 4 }, ]); + expect(res.provaBase).toBe('p1'); }); }); diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index f4d23f7..d52e632 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -122,6 +122,7 @@ export class QuestaoService { const questoesAll: QuestaoAllDTO[] = questoes.data.map((questao) => ({ _id: questao._id, provasContendo: provasMap.get(questao._id.toString()) ?? [], + provaBase: questao.provaBase ? questao.provaBase.toString() : null, enemArea: questao.enemArea, materia: questao.materia?.nome, status: questao.status, From dcc470d54158263c82c95b8b131c2d2e598dd4ac Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 20:07:48 -0300 Subject: [PATCH 56/65] =?UTF-8?q?fix(prova):=20numero-sync=20dentro=20da?= =?UTF-8?q?=20transacao=20(session-aware)=20[review=20Cr=C3=ADtico]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncNumeroNaProvaESimulados agora aceita session opcional e roda ANTES do commitTransaction nas 3 factories, na mesma session — se falhar, aborta a transacao em vez de deixar Prova.questoes[].numero dessincronizado pos-commit. ProvaRepository.update/SimuladoRepository.update aceitam session opcional. updateClassificacao segue sem session (fora de transacao). Testes ajustados. Co-Authored-By: Claude Opus 4.8 --- .../factory/custom_prova_factory.spec.ts | 7 ++++-- .../prova/factory/custom_prova_factory.ts | 24 ++++++++++--------- .../prova/factory/enem_2010_2016_factory.ts | 24 ++++++++++--------- .../factory/enem_2017_plus_factory.spec.ts | 7 ++++-- .../prova/factory/enem_2017_plus_factory.ts | 24 ++++++++++--------- .../question-container.helpers.spec.ts | 4 ++-- .../helpers/question-container.helpers.ts | 16 +++++++++---- src/modules/prova/prova.repository.ts | 6 ++--- src/modules/prova/prova.service.spec.ts | 4 ++-- src/modules/simulado/simulado.repository.ts | 4 ++-- 10 files changed, 69 insertions(+), 51 deletions(-) diff --git a/src/modules/prova/factory/custom_prova_factory.spec.ts b/src/modules/prova/factory/custom_prova_factory.spec.ts index 8431566..f1438b5 100644 --- a/src/modules/prova/factory/custom_prova_factory.spec.ts +++ b/src/modules/prova/factory/custom_prova_factory.spec.ts @@ -325,9 +325,12 @@ describe('CustomProvaFactory.updateQuestion — numero-sync', () => { } as any); expect(prova.questoes[0].numero).toBe(6); - expect(provaRepository.update).toHaveBeenCalledWith(prova); + expect(provaRepository.update).toHaveBeenCalledWith(prova, expect.anything()); expect(simulado.questoes[0].numero).toBe(6); - expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); + expect(simuladoRepository.update).toHaveBeenCalledWith( + simulado, + expect.anything(), + ); }); it('NÃO reconcilia quando o input não traz numero', async () => { diff --git a/src/modules/prova/factory/custom_prova_factory.ts b/src/modules/prova/factory/custom_prova_factory.ts index cb79627..766917d 100644 --- a/src/modules/prova/factory/custom_prova_factory.ts +++ b/src/modules/prova/factory/custom_prova_factory.ts @@ -144,6 +144,19 @@ export class CustomProvaFactory implements IProvaFactory { ); } await this.questaoRepository.updateQuestion(question); + // Numero-sync DENTRO da transação: reconcilia prova + simulados na mesma + // session — se falhar, aborta a transação (evita numero dessincronizado + // pós-commit). + if (question.numero != null) { + await syncNumeroNaProvaESimulados( + this.provaRepository, + this.simuladoRepository, + question.prova, + question._id, + question.numero, + session, + ); + } await session.commitTransaction(); } catch (error) { await session.abortTransaction(); @@ -151,17 +164,6 @@ export class CustomProvaFactory implements IProvaFactory { } finally { session.endSession(); } - - // Numero-sync (cutover): numero vive no subdoc; reconcilia prova + simulados. - if (question.numero != null) { - await syncNumeroNaProvaESimulados( - this.provaRepository, - this.simuladoRepository, - question.prova, - question._id, - question.numero, - ); - } } public async verifyNumberProva( diff --git a/src/modules/prova/factory/enem_2010_2016_factory.ts b/src/modules/prova/factory/enem_2010_2016_factory.ts index 56e056b..a4e28f8 100644 --- a/src/modules/prova/factory/enem_2010_2016_factory.ts +++ b/src/modules/prova/factory/enem_2010_2016_factory.ts @@ -312,6 +312,19 @@ export class Enem2010_2017Factory implements IProvaFactory { ); } await this.questaoRepository.updateQuestion(question); + // Numero-sync DENTRO da transação: reconcilia prova + simulados na mesma + // session — se falhar, aborta a transação (evita numero dessincronizado + // pós-commit). + if (question.numero != null) { + await syncNumeroNaProvaESimulados( + this.provaRepository, + this.simuladoRepository, + question.prova, + question._id, + question.numero, + session, + ); + } await session.commitTransaction(); } catch (error) { await session.abortTransaction(); @@ -319,17 +332,6 @@ export class Enem2010_2017Factory implements IProvaFactory { } finally { session.endSession(); } - - // Numero-sync (cutover): numero vive no subdoc; reconcilia prova + simulados. - if (question.numero != null) { - await syncNumeroNaProvaESimulados( - this.provaRepository, - this.simuladoRepository, - question.prova, - question._id, - question.numero, - ); - } } // Busca a frente pelo nome (reaproveitada da função createQuestion) diff --git a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts index 87c6429..a0f42c4 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.spec.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.spec.ts @@ -164,8 +164,11 @@ describe('Enem2017PlusFactory.updateQuestion — numero-sync (mudança pura de n expect(simuladoService.removeQuestionSimulados).not.toHaveBeenCalled(); // numero-sync reconciliou prova + simulado expect(prova.questoes[0].numero).toBe(11); - expect(provaRepository.update).toHaveBeenCalledWith(prova); + expect(provaRepository.update).toHaveBeenCalledWith(prova, expect.anything()); expect(simulado.questoes[0].numero).toBe(11); - expect(simuladoRepository.update).toHaveBeenCalledWith(simulado); + expect(simuladoRepository.update).toHaveBeenCalledWith( + simulado, + expect.anything(), + ); }); }); diff --git a/src/modules/prova/factory/enem_2017_plus_factory.ts b/src/modules/prova/factory/enem_2017_plus_factory.ts index 521194c..aeca8d4 100644 --- a/src/modules/prova/factory/enem_2017_plus_factory.ts +++ b/src/modules/prova/factory/enem_2017_plus_factory.ts @@ -289,6 +289,19 @@ export class Enem2017PlusFactory implements IProvaFactory { ); } await this.questaoRepository.updateQuestion(question); + // Numero-sync DENTRO da transação: reconcilia prova + simulados na mesma + // session — se falhar, aborta a transação (evita numero dessincronizado + // pós-commit). + if (question.numero != null) { + await syncNumeroNaProvaESimulados( + this.provaRepository, + this.simuladoRepository, + question.prova, + question._id, + question.numero, + session, + ); + } await session.commitTransaction(); } catch (error) { await session.abortTransaction(); @@ -296,17 +309,6 @@ export class Enem2017PlusFactory implements IProvaFactory { } finally { session.endSession(); } - - // Numero-sync (cutover): numero vive no subdoc; reconcilia prova + simulados. - if (question.numero != null) { - await syncNumeroNaProvaESimulados( - this.provaRepository, - this.simuladoRepository, - question.prova, - question._id, - question.numero, - ); - } } // --- Funções auxiliares --- diff --git a/src/modules/prova/helpers/question-container.helpers.spec.ts b/src/modules/prova/helpers/question-container.helpers.spec.ts index 340207b..85e7777 100644 --- a/src/modules/prova/helpers/question-container.helpers.spec.ts +++ b/src/modules/prova/helpers/question-container.helpers.spec.ts @@ -89,8 +89,8 @@ describe('syncNumeroNaProvaESimulados', () => { expect(prova.questoes[0].numero).toBe(9); expect(sml.questoes[0].numero).toBe(9); - expect(provaRepository.update).toHaveBeenCalledWith(prova); - expect(simuladoRepository.update).toHaveBeenCalledWith(sml); + expect(provaRepository.update).toHaveBeenCalledWith(prova, undefined); + expect(simuladoRepository.update).toHaveBeenCalledWith(sml, undefined); }); it('nao persiste quando numero nao muda', async () => { diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts index 509ce3f..34e4273 100644 --- a/src/modules/prova/helpers/question-container.helpers.ts +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -1,4 +1,4 @@ -import { Types } from 'mongoose'; +import { ClientSession, Types } from 'mongoose'; import { Questao } from '../../questao/questao.schema'; import { QuestaoNaContainer } from '../schemas/questao-na-container.schema'; @@ -38,10 +38,10 @@ export function removeQuestaoFromContainer( interface ProvaRepositoryLike { getById(id: string): Promise; - update(prova: QuestaoContainer): Promise; + update(prova: QuestaoContainer, session?: ClientSession): Promise; } interface SimuladoRepositoryLike { - update(simulado: QuestaoContainer): Promise; + update(simulado: QuestaoContainer, session?: ClientSession): Promise; } /** @@ -50,6 +50,11 @@ interface SimuladoRepositoryLike { * número vive no relacionamento, não propaga a outras provas. Idempotente: * `updateNumeroNoContainer` é no-op quando o número já está correto, então só * persiste os containers que de fato mudaram. + * + * `session` opcional: quando chamado dentro de uma transação (factories), os + * updates entram na mesma session — assim uma falha aqui aborta a transação em + * vez de deixar o `numero` dessincronizado pós-commit. Sem session (ex.: + * updateClassificacao) roda solto, como antes. */ export async function syncNumeroNaProvaESimulados( provaRepository: ProvaRepositoryLike, @@ -57,16 +62,17 @@ export async function syncNumeroNaProvaESimulados( provaId: string, questaoId: Types.ObjectId | string, numero: number, + session?: ClientSession, ): Promise { const prova = await provaRepository.getById(provaId); if (!prova) return; if (updateNumeroNoContainer(prova, questaoId, numero)) { - await provaRepository.update(prova); + await provaRepository.update(prova, session); } await Promise.all( (prova.simulados ?? []).map(async (sml) => { if (updateNumeroNoContainer(sml, questaoId, numero)) { - await simuladoRepository.update(sml); + await simuladoRepository.update(sml, session); } }), ); diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index 3e287de..1f1c677 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; +import { ClientSession, Model } from 'mongoose'; import { BaseRepository } from 'src/shared/base/base.repository'; import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; import { GetAllWhereInput } from 'src/shared/base/interfaces/get-all.input'; @@ -18,8 +18,8 @@ export class ProvaRepository extends BaseRepository { super(model); } - async update(prova: Prova) { - await this.model.updateOne({ _id: prova._id }, prova); + async update(prova: Prova, session?: ClientSession) { + await this.model.updateOne({ _id: prova._id }, prova, { session }); } async getProvaWithQuestion(id: string): Promise { diff --git a/src/modules/prova/prova.service.spec.ts b/src/modules/prova/prova.service.spec.ts index 3885ca2..b04c446 100644 --- a/src/modules/prova/prova.service.spec.ts +++ b/src/modules/prova/prova.service.spec.ts @@ -161,8 +161,8 @@ describe('ProvaService.syncNumero', () => { expect(prova.questoes[0].numero).toBe(9); expect(sml.questoes[0].numero).toBe(9); - expect(repository.update).toHaveBeenCalledWith(prova); - expect(simuladoRepository.update).toHaveBeenCalledWith(sml); + expect(repository.update).toHaveBeenCalledWith(prova, undefined); + expect(simuladoRepository.update).toHaveBeenCalledWith(sml, undefined); }); }); diff --git a/src/modules/simulado/simulado.repository.ts b/src/modules/simulado/simulado.repository.ts index 491b8b4..44ac85a 100644 --- a/src/modules/simulado/simulado.repository.ts +++ b/src/modules/simulado/simulado.repository.ts @@ -69,8 +69,8 @@ export class SimuladoRepository extends BaseRepository { }; } - async update(simulado: Simulado) { - await this.model.updateOne({ _id: simulado._id }, simulado); + async update(simulado: Simulado, session?: ClientSession) { + await this.model.updateOne({ _id: simulado._id }, simulado, { session }); } async updateSession(simulado: Simulado, session?: ClientSession) { From e0e00b492f12709c70dc868dcd1170be7f0a895a Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 20:09:23 -0300 Subject: [PATCH 57/65] fix(historico): getById preserva numero do relacionamento no achatamento [review] O achatamento de simulado.questoes descartava qc.numero; como numero saiu da Questao (vive em QuestaoNaContainer), o client recebia undefined. Agora mapeia { ...qc.questao, numero: qc.numero }, restaurando o contrato QuestaoHistorico.numero. Co-Authored-By: Claude Opus 4.8 --- src/modules/historico/historico.service.spec.ts | 9 +++++---- src/modules/historico/historico.service.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/modules/historico/historico.service.spec.ts b/src/modules/historico/historico.service.spec.ts index 6fffd8c..53f451e 100644 --- a/src/modules/historico/historico.service.spec.ts +++ b/src/modules/historico/historico.service.spec.ts @@ -80,8 +80,8 @@ describe('HistoricoService.getById (achata simulado.questoes)', () => { _id: 's1', nome: 'S', questoes: [ - { questao: { _id: 'q1', textoQuestao: 'a', numero: 5, prova: 'p1' }, numero: 1 }, - { questao: { _id: 'q2', textoQuestao: 'b', numero: 6, prova: 'p1' }, numero: 2 }, + { questao: { _id: 'q1', textoQuestao: 'a' }, numero: 1 }, + { questao: { _id: 'q2', textoQuestao: 'b' }, numero: 2 }, ], }, }), @@ -91,9 +91,10 @@ describe('HistoricoService.getById (achata simulado.questoes)', () => { const result: any = await service.getById('h1'); + // Achata pro shape do client preservando o numero do relacionamento (qc.numero). expect(result.simulado.questoes).toEqual([ - { _id: 'q1', textoQuestao: 'a', numero: 5, prova: 'p1' }, - { _id: 'q2', textoQuestao: 'b', numero: 6, prova: 'p1' }, + { _id: 'q1', textoQuestao: 'a', numero: 1 }, + { _id: 'q2', textoQuestao: 'b', numero: 2 }, ]); }); diff --git a/src/modules/historico/historico.service.ts b/src/modules/historico/historico.service.ts index bdd3480..462a661 100644 --- a/src/modules/historico/historico.service.ts +++ b/src/modules/historico/historico.service.ts @@ -24,9 +24,12 @@ export class HistoricoService { ? (historico as any).toObject() : historico; if (obj.simulado && Array.isArray(obj.simulado.questoes)) { - obj.simulado.questoes = obj.simulado.questoes.map( - (qc: any) => qc.questao, - ); + // Achata pro shape antigo do client, preservando o numero do relacionamento + // (numero vive em QuestaoNaContainer, não mais na Questao). + obj.simulado.questoes = obj.simulado.questoes.map((qc: any) => ({ + ...qc.questao, + numero: qc.numero, + })); } return obj; } From 4ea396feee81f42adfd988fa2e3e2f9d1dff7e5a Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 20:42:43 -0300 Subject: [PATCH 58/65] fix(simulado): guard em processAnswer para simulado sem questoes [review Alto] Evita TypeError em simulado.questoes[0] quando o array esta vazio: marca o historico como Failed e retorna cedo. Teste cobre o caminho vazio. Co-Authored-By: Claude Opus 4.8 --- src/modules/simulado/simulado.service.spec.ts | 34 +++++++++++++++++++ src/modules/simulado/simulado.service.ts | 10 ++++++ 2 files changed, 44 insertions(+) diff --git a/src/modules/simulado/simulado.service.spec.ts b/src/modules/simulado/simulado.service.spec.ts index 187c91f..94955f0 100644 --- a/src/modules/simulado/simulado.service.spec.ts +++ b/src/modules/simulado/simulado.service.spec.ts @@ -353,4 +353,38 @@ describe('SimuladoService.processAnswer (lê questoes)', () => { expect(payload.respostas[0].alternativaEstudante).toBe('A'); expect(payload.respostas[0].alternativaCorreta).toBe('A'); }); + + it('marca Failed sem crashar quando o simulado não tem questões', async () => { + const historicoRepository: any = { + claimForProcessing: jest.fn().mockResolvedValue(true), + getById: jest.fn().mockResolvedValue({ + simulado: 's1', + rawRespostas: [{ questao: 'q1', alternativaEstudante: 'A' }], + }), + updateStatus: jest.fn().mockResolvedValue(undefined), + completeProcessing: jest.fn().mockResolvedValue(undefined), + }; + const simuladoRepository: any = { + answer: jest.fn().mockResolvedValue({ _id: 's1', questoes: [] }), + }; + const questoesRepository: any = { + findAnoByQuestao: jest.fn(), + updateQuestionAnswered: jest.fn(), + }; + + const service = new SimuladoService( + simuladoRepository, + questoesRepository, + {} as any, + historicoRepository, + {} as any, + {} as any, + ); + + await expect(service.processAnswer('hist1')).resolves.toBeUndefined(); + + expect(questoesRepository.findAnoByQuestao).not.toHaveBeenCalled(); + expect(historicoRepository.completeProcessing).not.toHaveBeenCalled(); + expect(historicoRepository.updateStatus).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/modules/simulado/simulado.service.ts b/src/modules/simulado/simulado.service.ts index c35ec0e..e2412d1 100644 --- a/src/modules/simulado/simulado.service.ts +++ b/src/modules/simulado/simulado.service.ts @@ -207,6 +207,16 @@ export class SimuladoService { historico.simulado.toString(); const simulado = await this.simuladoRepository.answer(simuladoId); + // Guard: simulado sem questões não tem o que processar — evita + // TypeError no acesso a questoes[0] abaixo. + if (!simulado.questoes.length) { + await this.historicoRepository.updateStatus( + histId, + HistoricoStatus.Failed, + ); + return; + } + const ano = await this.questoesRepository.findAnoByQuestao( simulado.questoes[0].questao._id, ); From 1ae32dc1f09e0f72101eae01d24efd3e6a00cbe8 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 20:53:02 -0300 Subject: [PATCH 59/65] =?UTF-8?q?perf(prova):=20=C3=ADndice=20reverso=20qu?= =?UTF-8?q?estoes.questao=20no=20schema=20[review=20Alto]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declara ProvaSchema.index({ 'questoes.questao': 1 }) — sustenta os lookups do banco de questões (findProvasContendo/Many, findProvaAtual, findAnoByQuestao) sem depender do indices.sh manual (autoIndex cria no boot). Evita full scan em provas nas listagens. Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.schema.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/modules/prova/prova.schema.ts b/src/modules/prova/prova.schema.ts index a705555..6736072 100644 --- a/src/modules/prova/prova.schema.ts +++ b/src/modules/prova/prova.schema.ts @@ -78,3 +78,8 @@ export class Prova extends BaseSchema { export const ProvaSchema = SchemaFactory.createForClass(Prova); ProvaSchema.index({ cursinhoId: 1 }); + +// Índice reverso questao -> provas que a contêm. Sustenta os lookups do banco de +// questões (findProvasContendo/Many, findProvaAtual, findAnoByQuestao). Declarado +// no schema (autoIndex cria no boot) pra não depender do indices.sh manual. +ProvaSchema.index({ 'questoes.questao': 1 }); From d13dfbc3fc70413f5e9f8bf7352f0ddd6c6fa3d8 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 20:58:44 -0300 Subject: [PATCH 60/65] fix(questao): guard no updateClassificacao numero-only se prova nao contem a questao [review] Em vez de syncNumero virar no-op silencioso quando classificacao.prova nao contem a questao, valida via provaContemQuestao e lanca BAD_REQUEST. Mantem o design do ponto #1 (usa a prova enviada pela UI travada, sem findProvaAtual arbitrario). Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/questao.repository.ts | 11 +++++++++ src/modules/questao/questao.service.spec.ts | 25 +++++++++++++++++++++ src/modules/questao/questao.service.ts | 13 +++++++++++ 3 files changed, 49 insertions(+) diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index 2fbbcb4..04aa3d0 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -90,6 +90,17 @@ export class QuestaoRepository extends BaseRepository { return prova?.ano; } + async provaContemQuestao( + provaId: string, + questaoId: string, + ): Promise { + const found = await this.provaModel.exists({ + _id: provaId, + 'questoes.questao': questaoId, + }); + return !!found; + } + async getByIdToUpdate(id: string) { return await this.model .findById(id) diff --git a/src/modules/questao/questao.service.spec.ts b/src/modules/questao/questao.service.spec.ts index 11cffb3..d29a55c 100644 --- a/src/modules/questao/questao.service.spec.ts +++ b/src/modules/questao/questao.service.spec.ts @@ -225,6 +225,7 @@ describe('QuestaoService.updateClassificacao', () => { const repository: any = { getByIdToUpdate: jest.fn().mockResolvedValue(questao), updateClassificacao: jest.fn().mockResolvedValue(undefined), + provaContemQuestao: jest.fn().mockResolvedValue(true), }; const provaService: any = { syncNumero: jest.fn().mockResolvedValue(undefined) }; const provaFactory: any = { getFactory: jest.fn() }; @@ -238,11 +239,35 @@ describe('QuestaoService.updateClassificacao', () => { prova: 'p1', enemArea: 'Mat', frente1: 'f1', materia: 'm1', numero: 7, } as any); + expect(repository.provaContemQuestao).toHaveBeenCalledWith('p1', 'q1'); expect(provaService.syncNumero).toHaveBeenCalledWith('p1', 'q1', 7); expect(provaFactory.getFactory).not.toHaveBeenCalled(); expect(repository.updateClassificacao).toHaveBeenCalledWith('q1', expect.anything()); }); + it('numero-only: falha alto se a prova enviada não contém a questão', async () => { + const repository: any = { + getByIdToUpdate: jest.fn().mockResolvedValue(questao), + updateClassificacao: jest.fn().mockResolvedValue(undefined), + provaContemQuestao: jest.fn().mockResolvedValue(false), + }; + const provaService: any = { syncNumero: jest.fn() }; + const { QuestaoService } = require('./questao.service'); + const service = new QuestaoService( + repository, provaService, {} as any, {} as any, {} as any, + {} as any, {} as any, {} as any, {} as any, + ); + + await expect( + service.updateClassificacao('q1', { + prova: 'p1', enemArea: 'Mat', frente1: 'f1', materia: 'm1', numero: 7, + } as any), + ).rejects.toBeTruthy(); + + expect(provaService.syncNumero).not.toHaveBeenCalled(); + expect(repository.updateClassificacao).not.toHaveBeenCalled(); + }); + it('enemArea mudou: dispara factory.updateQuestion e NAO syncNumero', async () => { const repository: any = { getByIdToUpdate: jest.fn().mockResolvedValue(questao), diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index d52e632..9bdd3ec 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -276,6 +276,19 @@ export class QuestaoService { updateDto.alternativa = questao.alternativa; await this.updateQuestion(updateDto); } else if (classificacao.numero != null) { + // Guard: no branch numero-only assumimos que a questão já está na prova + // enviada (a UI trava a prova). Falha alto se não estiver, em vez de o + // syncNumero virar no-op silencioso na prova errada. + const naProva = await this.repository.provaContemQuestao( + classificacao.prova, + id, + ); + if (!naProva) { + throw new HttpException( + `A questão ${id} não está na prova ${classificacao.prova}.`, + HttpStatus.BAD_REQUEST, + ); + } // Só o numero mudou: sync escopado na prova editada + simulados dela. await this.provaService.syncNumero( classificacao.prova, From 974037a81933a26ba64acb5d0cc61609143ee9ca Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 21:03:45 -0300 Subject: [PATCH 61/65] refactor(questao): tipa getById com ProvaContendo em vez de any [review] Exporta ProvaContendo (reusado por findProvasContendoMany) e tipa getById como Questao & { provasContendo: ProvaContendo[] } | null, propagando pro controller. Restaura type-safety sem criar DTO novo. Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/questao.controller.ts | 5 ++++- src/modules/questao/questao.repository.ts | 11 +++++++++-- src/modules/questao/questao.service.ts | 12 +++++++----- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/modules/questao/questao.controller.ts b/src/modules/questao/questao.controller.ts index b5445b5..979e65f 100644 --- a/src/modules/questao/questao.controller.ts +++ b/src/modules/questao/questao.controller.ts @@ -20,6 +20,7 @@ import { UpdateImageAlternativaDTOInput } from './dtos/update-image-alternativa. import { UpdateImageIdDTOInput } from './dtos/update-image-id.dto.input'; import { UpdateDTOInput } from './dtos/update.dto.input'; import { Status } from './enums/status.enum'; +import { ProvaContendo } from './questao.repository'; import { Questao } from './questao.schema'; import { QuestaoService } from './questao.service'; @@ -104,7 +105,9 @@ export class QuestaoController { type: Questao, isArray: false, }) - public async getById(@Param('id') id: string): Promise { + public async getById( + @Param('id') id: string, + ): Promise<(Questao & { provasContendo: ProvaContendo[] }) | null> { return await this.service.getById(id); } diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index 04aa3d0..f0ab2fe 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -14,6 +14,13 @@ import { UpdateDTOInput } from './dtos/update.dto.input'; import { Status } from './enums/status.enum'; import { Questao } from './questao.schema'; +/** Entrada do reverse-lookup: prova que contém a questão + o número nela. */ +export interface ProvaContendo { + provaId: string; + provaNome: string; + numero: number; +} + @Injectable() export class QuestaoRepository extends BaseRepository { constructor( @@ -259,12 +266,12 @@ export class QuestaoRepository extends BaseRepository { async findProvasContendoMany( questaoIds: string[], - ): Promise> { + ): Promise> { const provas = await this.provaModel .find({ 'questoes.questao': { $in: questaoIds } }) .select('nome questoes') .exec(); - const map = new Map(); + const map = new Map(); for (const prova of provas) { for (const qc of (prova as any).questoes) { const qId = ((qc.questao as any)?._id ?? qc.questao).toString(); diff --git a/src/modules/questao/questao.service.ts b/src/modules/questao/questao.service.ts index 9bdd3ec..0724480 100644 --- a/src/modules/questao/questao.service.ts +++ b/src/modules/questao/questao.service.ts @@ -26,7 +26,7 @@ import { UpdateImageAlternativaDTOInput } from './dtos/update-image-alternativa. import { UpdateImageIdDTOInput } from './dtos/update-image-id.dto.input'; import { UpdateDTOInput } from './dtos/update.dto.input'; import { Status } from './enums/status.enum'; -import { QuestaoRepository } from './questao.repository'; +import { ProvaContendo, QuestaoRepository } from './questao.repository'; import { Questao } from './questao.schema'; @Injectable() @@ -57,13 +57,15 @@ export class QuestaoService { ); } - public async getById(id: string): Promise { + public async getById( + id: string, + ): Promise<(Questao & { provasContendo: ProvaContendo[] }) | null> { const questao = await this.repository.getById(id); - if (!questao) return questao; + if (!questao) return null; const map = await this.repository.findProvasContendoMany([id]); - const obj: any = (questao as any).toObject + const obj = ((questao as any).toObject ? (questao as any).toObject() - : questao; + : questao) as Questao & { provasContendo: ProvaContendo[] }; obj.provasContendo = map.get(id.toString()) ?? []; return obj; } From 03c8c57d9db57f68a993963bb930512b762f3ec3 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 22:21:04 -0300 Subject: [PATCH 62/65] fix(questao): remove provider SimuladoService duplicado no module [review] Co-Authored-By: Claude Opus 4.8 --- src/modules/questao/questao.module.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/questao/questao.module.ts b/src/modules/questao/questao.module.ts index 9269e71..773802c 100644 --- a/src/modules/questao/questao.module.ts +++ b/src/modules/questao/questao.module.ts @@ -50,7 +50,6 @@ import { QuestaoService } from './questao.service'; SimuladoRepository, ProvaFactory, EnemService, - SimuladoService, ], controllers: [QuestaoController], exports: [QuestaoService, QuestaoRepository], From 9929a8d29f2f949354905605cd7c572d163488e1 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 22:21:04 -0300 Subject: [PATCH 63/65] fix(prova): removeQuestion nao persiste quando a questao nao estava na prova [review] Co-Authored-By: Claude Opus 4.8 --- src/modules/prova/prova.repository.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/modules/prova/prova.repository.ts b/src/modules/prova/prova.repository.ts index 1f1c677..d790bd4 100644 --- a/src/modules/prova/prova.repository.ts +++ b/src/modules/prova/prova.repository.ts @@ -54,10 +54,9 @@ export class ProvaRepository extends BaseRepository { const prova = await this.model.findById(id); const before = prova.questoes.length; removeQuestaoFromContainer(prova, oldQuestao._id); - if ( - prova.questoes.length !== before && - oldQuestao.status === Status.Approved - ) { + // Questão não estava na prova: nada mudou → evita write espúrio. + if (prova.questoes.length === before) return; + if (oldQuestao.status === Status.Approved) { prova.totalQuestaoValidadas -= 1; } await this.model.updateOne({ _id: prova._id }, prova); From 297a8fef5c0653c8a2c0e9283b36c4f88d9f1326 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 22:21:04 -0300 Subject: [PATCH 64/65] refactor(prova): extrai resolveQuestaoId e dedup normalizacao de id [review] Co-Authored-By: Claude Opus 4.8 --- .../helpers/question-container.helpers.ts | 23 +++++++++++-------- src/modules/questao/questao.repository.ts | 7 +++--- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/modules/prova/helpers/question-container.helpers.ts b/src/modules/prova/helpers/question-container.helpers.ts index 34e4273..f2fce2f 100644 --- a/src/modules/prova/helpers/question-container.helpers.ts +++ b/src/modules/prova/helpers/question-container.helpers.ts @@ -21,19 +21,25 @@ export function addQuestaoToContainer( } /** - * Remove do container a entry cuja questão bate com `questaoId`. Normaliza o id - * para funcionar tanto com objeto completo (`qc.questao._id`) quanto com ref - * crua (`qc.questao` sendo o próprio ObjectId). + * Normaliza o id da questão de uma entry, funcionando tanto com objeto completo + * (`qc.questao._id`, populado) quanto com ref crua (`qc.questao` = ObjectId). + */ +export function resolveQuestaoId(qc: QuestaoNaContainer): string { + const q = qc.questao as any; + return (q?._id ?? q).toString(); +} + +/** + * Remove do container a entry cuja questão bate com `questaoId`. */ export function removeQuestaoFromContainer( container: QuestaoContainer, questaoId: Types.ObjectId | string, ): void { const idStr = questaoId.toString(); - container.questoes = container.questoes.filter((qc) => { - const cur = (qc.questao as any)?._id ?? qc.questao; - return cur.toString() !== idStr; - }); + container.questoes = container.questoes.filter( + (qc) => resolveQuestaoId(qc) !== idStr, + ); } interface ProvaRepositoryLike { @@ -92,8 +98,7 @@ export function updateNumeroNoContainer( const idStr = questaoId.toString(); let changed = false; for (const qc of container.questoes) { - const cur = (qc.questao as any)?._id ?? qc.questao; - if (cur.toString() === idStr && qc.numero !== numero) { + if (resolveQuestaoId(qc) === idStr && qc.numero !== numero) { qc.numero = numero; changed = true; } diff --git a/src/modules/questao/questao.repository.ts b/src/modules/questao/questao.repository.ts index f0ab2fe..c8cdd8d 100644 --- a/src/modules/questao/questao.repository.ts +++ b/src/modules/questao/questao.repository.ts @@ -5,6 +5,7 @@ import { BaseRepository } from 'src/shared/base/base.repository'; import { GetAllWhereInput } from 'src/shared/base/interfaces/get-all.input'; import { GetAllOutput } from 'src/shared/base/interfaces/get-all.output'; import { Prova } from '../prova/prova.schema'; +import { resolveQuestaoId } from '../prova/helpers/question-container.helpers'; import { Resposta } from '../historico/types/resposta'; import { UpdateClassificacaoDTOInput } from './dtos/update-classificacao.dto.input'; import { UpdateContentDTOInput } from './dtos/update-content.dto.input'; @@ -259,9 +260,7 @@ export class QuestaoRepository extends BaseRepository { async findQuestaoIdsByProva(provaId: string): Promise { const prova = await this.provaModel.findById(provaId).select('questoes').exec(); if (!prova) return []; - return prova.questoes.map((qc: any) => - ((qc.questao as any)?._id ?? qc.questao).toString(), - ); + return prova.questoes.map((qc) => resolveQuestaoId(qc)); } async findProvasContendoMany( @@ -274,7 +273,7 @@ export class QuestaoRepository extends BaseRepository { const map = new Map(); for (const prova of provas) { for (const qc of (prova as any).questoes) { - const qId = ((qc.questao as any)?._id ?? qc.questao).toString(); + const qId = resolveQuestaoId(qc); if (!questaoIds.includes(qId)) continue; if (!map.has(qId)) map.set(qId, []); map.get(qId)!.push({ From 70e799f8bff11e980edbbffbb6a9bf6b67959ae3 Mon Sep 17 00:00:00 2001 From: Fernando Almeida Date: Sun, 9 Aug 2026 22:50:17 -0300 Subject: [PATCH 65/65] fix(migration-0001): aceita Questao.prova como objeto oid ou string Dados legados tem prova ora como ObjectId (extended JSON com oid), ora como string hex no mongoexport. migrate.sh/validate.sh normalizam ambos os formatos ao popular questoesNovo e nos checks de validacao. Co-Authored-By: Claude Opus 4.8 --- .../migrations/0001-questoes-em-multiplas-provas/migrate.sh | 4 ++-- .../0001-questoes-em-multiplas-provas/validate.sh | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) mode change 100644 => 100755 scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh diff --git a/scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh b/scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh old mode 100644 new mode 100755 index fc00c13..d1f758d --- a/scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh +++ b/scripts/migrations/0001-questoes-em-multiplas-provas/migrate.sh @@ -46,7 +46,7 @@ jq -n \ | ($provas[0]) as $ps | ( [ $qs[] | select(.prova != null and (.numero != null)) - | { provaId: .prova["$oid"], questao: {"$oid": ._id["$oid"]}, numero: .numero } ] + | { provaId: (.prova | if type == "object" then .["$oid"] else . end), questao: {"$oid": ._id["$oid"]}, numero: .numero } ] | to_entries | map(.value + { _id: {"$oid": $idpool[.key]} }) ) as $entries | ( $entries @@ -63,7 +63,7 @@ orfas=$(jq -n --slurpfile q "$QUESTOES" --slurpfile p "$PROVAS" ' ($p[0] | map(._id["$oid"])) as $ids | [ $q[0][] | select(.prova != null and (.numero != null)) - | select((.prova["$oid"]) as $pid | ($ids | index($pid)) | not) ] | length') + | select(((.prova | if type == "object" then .["$oid"] else . end)) as $pid | ($ids | index($pid)) | not) ] | length') echo "[STEP 2b] Questoes órfãs (prova fora do dump): $orfas" >&2 # ============ SIMULADO ============ diff --git a/scripts/migrations/0001-questoes-em-multiplas-provas/validate.sh b/scripts/migrations/0001-questoes-em-multiplas-provas/validate.sh index 094c5fe..24f5b87 100644 --- a/scripts/migrations/0001-questoes-em-multiplas-provas/validate.sh +++ b/scripts/migrations/0001-questoes-em-multiplas-provas/validate.sh @@ -23,7 +23,7 @@ fail=0 expected=$(jq -n --slurpfile q "$QUESTOES" --slurpfile p "$PROVAS_OUT" ' ($p[0] | map(._id["$oid"])) as $ids | [ $q[0][] | select(.prova != null and (.numero != null)) - | select((.prova["$oid"]) as $pid | ($ids | index($pid))) ] | length') + | select(((.prova | if type == "object" then .["$oid"] else . end)) as $pid | ($ids | index($pid))) ] | length') got=$(jq '[.[] | .questoesNovo | length] | add // 0' "$PROVAS_OUT") if [ "$expected" = "$got" ]; then echo "✓ [1] Total prova entries = $got (esperado $expected)" @@ -34,7 +34,7 @@ fi # [2] Idiomáticas preservadas: nº de grupos (prova,numero) com >=2 igual input vs output exp_pairs=$(jq -n --slurpfile q "$QUESTOES" ' [ $q[0][] | select(.prova != null and (.numero != null)) - | (.prova["$oid"] + "|" + (.numero | tostring)) ] + | ((.prova | if type == "object" then .["$oid"] else . end) + "|" + (.numero | tostring)) ] | group_by(.) | map(select(length >= 2)) | length') got_pairs=$(jq ' [ .[] | (.questoesNovo // []) @@ -63,7 +63,7 @@ fi orfas=$(jq -n --slurpfile q "$QUESTOES" --slurpfile p "$PROVAS_OUT" ' ($p[0] | map(._id["$oid"])) as $ids | [ $q[0][] | select(.prova != null and (.numero != null)) - | select((.prova["$oid"]) as $pid | ($ids | index($pid)) | not) ] | length') + | select(((.prova | if type == "object" then .["$oid"] else . end)) as $pid | ($ids | index($pid)) | not) ] | length') echo "ℹ [4] Questoes órfãs (prova fora do dump): $orfas (warn)" if [ "$fail" -eq 0 ]; then