From ae7960294f323a5f22c7286468eab1136465bf9a Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 22 Jun 2026 18:54:50 +0000 Subject: [PATCH] feat(api): endpoint dry-run POST /v1/prezentari/valideaza (PRD 5.2) Valideaza payload + mapare si intoarce verdictul real (status_estimat queued/needs_data/needs_mapping + erori [{field,message}] + coduri nemapate + prestatii rezolvate) FARA enqueue, fara creds, zero scriere DB. "Magical moment" pentru integratori (ROAAUTO / soft propriu / punte VFP). Cheia de design: helper pur partajat classify_prezentare (mapping.py) folosit de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala (invariant de corectitudine). create_prezentari refactorizat pe el cu comportament identic (test_api.py verde). Scope minim (decizie user): doar validare+mapare, fara idempotency/duplicat (idempotency.py neatins); descoperibilitate in hub /integrare amanata. VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0 dupa dry-run). /code-review high: 0 findings. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/router.py | 81 ++++++++------ app/mapping.py | 55 +++++++++ app/models.py | 22 ++++ docs/ROADMAP.md | 4 +- docs/prd/prd-5.2-dryrun-valideaza.md | 159 +++++++++++++++++++++++++++ tests/test_validare_dryrun.py | 159 +++++++++++++++++++++++++++ 6 files changed, 445 insertions(+), 35 deletions(-) create mode 100644 docs/prd/prd-5.2-dryrun-valideaza.md create mode 100644 tests/test_validare_dryrun.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 91e7f16..9a51b5e 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -22,20 +22,25 @@ from pydantic import BaseModel, Field from ...auth import resolve_account_id from ...crypto import encrypt_creds from ...db import get_connection -from ...idempotency import build_key, canonicalize_row, idempotency_key +from ...idempotency import build_key, canonicalize_row from ...mapping import ( account_or_default, account_scope_clause, - has_no_auto_send, + classify_prezentare, load_mapping_meta, pending_unmapped, reresolve_account, - resolve_prestatii, save_mapping, ) -from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult +from ...models import ( + PrezentareRequest, + PrezentariResponse, + SubmissionResult, + ValidarePrezentariRequest, + ValidareResponse, + ValidareResult, +) from ...payload_view import prezentare_din_payload -from ...validation import validate_prezentare router = APIRouter(prefix="/v1", tags=["v1"]) @@ -94,43 +99,53 @@ def create_prezentari( ) continue - # Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul - # intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite), - # apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci - # validarea T3 + payload builder + worker raman code-driven. - resolved, unmapped = resolve_prestatii(content.get("prestatii"), mapping) - content["prestatii"] = resolved - - if unmapped: - status = "needs_mapping" - rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False) - else: - # T3: validare de continut -> queued daca e curat, altfel needs_data + motiv. - errors = validate_prezentare(content) - if errors: - status, rar_error = "needs_data", json.dumps(errors, ensure_ascii=False) - elif has_no_auto_send(resolved, mapping_meta): - # T6/OV-1: cod rezolvat cu auto_send=0 -> nu trimite automat. - # Randul ramane 'needs_mapping' pana userul confirma manual (sau comuta auto_send=1). - status = "needs_mapping" - rar_error = json.dumps( - {"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"}, - ensure_ascii=False, - ) - else: - status, rar_error = "queued", None - + # Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea + # (canonicalize + mapare op->cod + validare + auto_send gate). + cl = classify_prezentare(content, mapping, mapping_meta) cur = conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) " "VALUES (?, ?, ?, ?, ?, ?)", - (key, acct, status, json.dumps(content, ensure_ascii=False), rar_error, creds_enc), + (key, acct, cl["status"], json.dumps(cl["content"], ensure_ascii=False), cl["rar_error"], creds_enc), ) - results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status)) + results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=cl["status"])) finally: conn.close() return PrezentariResponse(results=results) +@router.post("/prezentari/valideaza", response_model=ValidareResponse) +def valideaza_prezentari( + req: ValidarePrezentariRequest, + account_id: int = Depends(resolve_account_id), +) -> ValidareResponse: + """Dry-run: valideaza payload exact ca POST /prezentari, fara enqueue si fara efecte secundare. + + Intoarce pentru fiecare prezentare: verdictul (status_estimat), erorile de + continut si codurile nemapate — exact ce ar obtine trimiterea reala pe acelasi + payload + aceeasi mapare de cont. rar_credentials ignorat complet (PRD 5.2). + """ + acct = account_or_default(account_id) + conn = get_connection() + results: list[ValidareResult] = [] + try: + mapping_meta = load_mapping_meta(conn, acct) + mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()} + for i, prez in enumerate(req.prezentari): + content = prez.model_dump() + res = classify_prezentare(content, mapping, mapping_meta) + results.append(ValidareResult( + index=i, + valid=(res["status"] == "queued"), + status_estimat=res["status"], + erori=res["errors"], + nemapate=res["unmapped"], + prestatii_rezolvate=res["resolved"], + )) + finally: + conn.close() + return ValidareResponse(results=results) + + @router.get("/prezentari") def list_prezentari( status: str | None = None, diff --git a/app/mapping.py b/app/mapping.py index 564a3d2..2e8eb30 100644 --- a/app/mapping.py +++ b/app/mapping.py @@ -217,6 +217,61 @@ def load_mapping_meta(conn, account_id: int | None) -> dict[str, dict]: } +def classify_prezentare( + content: dict, + mapping: dict[str, str], + mapping_meta: dict[str, dict], +) -> dict: + """Helper pur de clasificare: reproduce EXACT logica create_prezentari fara DB/efecte. + + Apelat de AMBELE rute (POST /v1/prezentari si POST /v1/prezentari/valideaza) pentru + a garanta acelasi verdict — invariantul de corectitudine dry-run (PRD 5.2). + + Intoarce {"status", "rar_error", "resolved", "unmapped", "errors", "content"}. + "content" = copia actualizata (VIN/nr canonicalizat + prestatii rezolvate). + """ + from .idempotency import canonicalize_row # import local: evita circular (mapping <- idempotency) + + c = dict(content) + canon = canonicalize_row(c) + c.update({ + "vin": canon["vin"], + "nr_inmatriculare": canon["nr_inmatriculare"], + "odometru_final": canon["odometru_final"], + }) + + resolved, unmapped = resolve_prestatii(c.get("prestatii"), mapping) + c["prestatii"] = resolved + + if unmapped: + status = "needs_mapping" + rar_error = json.dumps({"unmapped": unmapped}, ensure_ascii=False) + errors: list[dict] = [] + else: + errors = validate_prezentare(c) + if errors: + status = "needs_data" + rar_error = json.dumps(errors, ensure_ascii=False) + elif has_no_auto_send(resolved, mapping_meta): + status = "needs_mapping" + rar_error = json.dumps( + {"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"}, + ensure_ascii=False, + ) + else: + status = "queued" + rar_error = None + + return { + "status": status, + "rar_error": rar_error, + "resolved": resolved, + "unmapped": unmapped, + "errors": errors, + "content": c, + } + + def has_no_auto_send(resolved: list[dict], mapping_meta: dict[str, dict]) -> bool: """Verifica daca vreun item rezolvat via mapping are auto_send=0. diff --git a/app/models.py b/app/models.py index d7b92cd..7397c51 100644 --- a/app/models.py +++ b/app/models.py @@ -95,3 +95,25 @@ class SubmissionResult(BaseModel): class PrezentariResponse(BaseModel): results: list[SubmissionResult] + + +class ValidarePrezentariRequest(BaseModel): + """Body pentru POST /v1/prezentari/valideaza — dry-run fara enqueue (PRD 5.2).""" + + rar_credentials: RarCredentials | None = None + prezentari: list[PrezentareIn] = Field(..., min_length=1) + + +class ValidareResult(BaseModel): + """Verdictul dry-run per prezentare.""" + + index: int + valid: bool + status_estimat: str # "queued" | "needs_data" | "needs_mapping" + erori: list[dict] = [] + nemapate: list[dict] = [] + prestatii_rezolvate: list[dict] = [] + + +class ValidareResponse(BaseModel): + results: list[ValidareResult] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 25a9cae..09ead2a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,7 +48,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". -**Ultima actualizare**: 2026-06-22 — 5.1 LIVRAT (Hub de integrare `/integrare`: exemple cod multi-limbaj + retetar VFP cu 2 dialecte + `GET /v1/ping` readiness + export Postman/OpenAPI + "Testeaza conexiunea"). 4 stories in 2 valuri (Val 1 = US-001/US-002/US-004 paralel pe fisiere disjuncte via Agent team; Val 2 = US-003 UI). Atentie operationala: US-003 a rulat intr-un worktree branched din ultimul commit (FARA modificarile necomise ale US-004 din working-tree) si la "copiere manuala" a SUPRASCRIS `routes.py`, stergand ruta `POST /integrare/test-cheie` (8 teste 404) — reparat prin re-aplicarea rutei de catre autorul US-004 pe `routes.py` curent. Lectie: stories care ating acelasi fisier in valuri diferite + worktree = clobber daca worktree-ul nu vede working-tree-ul; foloseste fisiere disjuncte SAU merge atent de catre lead. VERIFY context curat PASS (568 teste) + E2E browser Playwright (deep-link server-side, IA pe 2 niveluri, VFP cu 3 niveluri de tab comuta corect, copy, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live (`POST /v1/prezentari` → queued); live RAR `FINALIZATA` NEPROBAT in sesiune (lipsa `AUTOPASS_CREDS_KEY`/creds RAR test) — risc minim, backend trimitere NEATINS. `/code-review` high a prins 4 bug-uri reale (toate in suprafata noua, reparate + lock-uite cu teste): snippet C# JSON multi-linie nevalid (CS1010), snippet VFP `json.dumps(indent=0)` inca cu newline-uri → string literal rupt in ambele dialecte, snippet Node `node:buffer` nu exporta FormData → TypeError, script `_integrare.html` ne-scoped acumuland event-listeneri pe tab-bar-ul principal la fiecare swap htmx (scoped pe `#integrare-section`). Notat ca cleanup viitor (nereparat): `_render_integrare` dubleaza SQL `are_creds`/`are_cheie`, `ping` cu 2 conexiuni DB + `account_for_key` de 2 ori, `_campuri_obligatorii` necache-uit, panouri limbaj copy-paste (candidat macro Jinja2). Backend trimitere (worker/masina stari/idempotenta/mapping) si schema NEATINSE. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md). | ISTORIC: 3.6 INCHIS (editare celule in preview + Acasa unificata). CLOSE: `/code-review` high a prins 1 bug real (decriptare `override_json` neprotejata de try/except in ambele cai de preview — 500 pe tot batch-ul la rotatie cheie Fernet vs. `raw_json` care degrada gratios), reparat in `import_router.preview_import` + `routes._web_compute_preview`; duplicarea `_override_of`/canonicalize notata ca cleanup viitor. 523 teste pass. 7 stories in 3 valuri, executate de 2 echipe in paralel (TeamCreate) pe fisiere disjuncte (core: routes/import/templates; mapari: `_mapari.html`) + US-007 secvential. Livrate: tab "Trimiteri" eliminat→sectiune "Trimiterile tale" sub upload pe Acasa (US-003); upload bara slim accentuata cu hero la first-run (US-004); editare de celule in preview prin `import_rows.override_json` (Approach B, Fernet, patch canonic aplicat ULTIMUL in `_resolve_row_for_preview`+`commit_import` — completeaza inclusiv coloane ABSENTE din fisier), mutatie pura cu status rederivat (US-001); buton Editeaza pe rand cu swap pe ``+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). +**Ultima actualizare**: 2026-06-22 — 5.2 LIVRAT (Endpoint dry-run `POST /v1/prezentari/valideaza`: valideaza payload + mapare si intoarce verdictul real — `status_estimat` queued/needs_data/needs_mapping + erori `[{field,message}]` + coduri nemapate + prestatii rezolvate — FARA enqueue, FARA creds, zero scriere DB). 1 story TDD. Cheia de design: helper pur partajat `classify_prezentare` folosit de AMBELE rute, ca dry-run-ul sa nu poata diverge de trimiterea reala (invariant de corectitudine); `create_prezentari` refactorizat pe el cu comportament identic. Scope minim per decizie user: doar validare+mapare (fara idempotency/duplicat, `idempotency.py` neatins), hub `/integrare` amanat ca follow-up (descoperibilitate). VERIFY context curat PASS (577 teste; E2E API cu cele 3 verdicte + COUNT(*)=0 dupa dry-run + fara leak creds in raspuns; regresia de aur verde; live RAR `FINALIZATA` neprobat — lipsa creds key, endpoint read-only nu atinge worker/coada/schema). `/code-review` high: 0 findings (refactor faithful, mutable-default Pydantic-safe, import local necesar anti-circular). PRD: [prd-5.2](prd/prd-5.2-dryrun-valideaza.md). | ISTORIC: 5.1 LIVRAT (Hub de integrare `/integrare`: exemple cod multi-limbaj + retetar VFP cu 2 dialecte + `GET /v1/ping` readiness + export Postman/OpenAPI + "Testeaza conexiunea"). 4 stories in 2 valuri (Val 1 = US-001/US-002/US-004 paralel pe fisiere disjuncte via Agent team; Val 2 = US-003 UI). Atentie operationala: US-003 a rulat intr-un worktree branched din ultimul commit (FARA modificarile necomise ale US-004 din working-tree) si la "copiere manuala" a SUPRASCRIS `routes.py`, stergand ruta `POST /integrare/test-cheie` (8 teste 404) — reparat prin re-aplicarea rutei de catre autorul US-004 pe `routes.py` curent. Lectie: stories care ating acelasi fisier in valuri diferite + worktree = clobber daca worktree-ul nu vede working-tree-ul; foloseste fisiere disjuncte SAU merge atent de catre lead. VERIFY context curat PASS (568 teste) + E2E browser Playwright (deep-link server-side, IA pe 2 niveluri, VFP cu 3 niveluri de tab comuta corect, copy, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live (`POST /v1/prezentari` → queued); live RAR `FINALIZATA` NEPROBAT in sesiune (lipsa `AUTOPASS_CREDS_KEY`/creds RAR test) — risc minim, backend trimitere NEATINS. `/code-review` high a prins 4 bug-uri reale (toate in suprafata noua, reparate + lock-uite cu teste): snippet C# JSON multi-linie nevalid (CS1010), snippet VFP `json.dumps(indent=0)` inca cu newline-uri → string literal rupt in ambele dialecte, snippet Node `node:buffer` nu exporta FormData → TypeError, script `_integrare.html` ne-scoped acumuland event-listeneri pe tab-bar-ul principal la fiecare swap htmx (scoped pe `#integrare-section`). Notat ca cleanup viitor (nereparat): `_render_integrare` dubleaza SQL `are_creds`/`are_cheie`, `ping` cu 2 conexiuni DB + `account_for_key` de 2 ori, `_campuri_obligatorii` necache-uit, panouri limbaj copy-paste (candidat macro Jinja2). Backend trimitere (worker/masina stari/idempotenta/mapping) si schema NEATINSE. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md). | ISTORIC: 3.6 INCHIS (editare celule in preview + Acasa unificata). CLOSE: `/code-review` high a prins 1 bug real (decriptare `override_json` neprotejata de try/except in ambele cai de preview — 500 pe tot batch-ul la rotatie cheie Fernet vs. `raw_json` care degrada gratios), reparat in `import_router.preview_import` + `routes._web_compute_preview`; duplicarea `_override_of`/canonicalize notata ca cleanup viitor. 523 teste pass. 7 stories in 3 valuri, executate de 2 echipe in paralel (TeamCreate) pe fisiere disjuncte (core: routes/import/templates; mapari: `_mapari.html`) + US-007 secvential. Livrate: tab "Trimiteri" eliminat→sectiune "Trimiterile tale" sub upload pe Acasa (US-003); upload bara slim accentuata cu hero la first-run (US-004); editare de celule in preview prin `import_rows.override_json` (Approach B, Fernet, patch canonic aplicat ULTIMUL in `_resolve_row_for_preview`+`commit_import` — completeaza inclusiv coloane ABSENTE din fisier), mutatie pura cu status rederivat (US-001); buton Editeaza pe rand cu swap pe ``+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). > 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). @@ -97,7 +97,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi | # | Livrabila | Status | Data | Detalii | |---|-----------|--------|------|---------| | 5.1 | Hub de integrare (pagina `/integrare` autentificata): exemple cod multi-limbaj (curl/Python/PHP/C#/Node) + retetar **Visual FoxPro** (POST via `MSXML2.ServerXMLHTTP` + upload CSV) + export OpenAPI/Postman + buton "Testeaza conexiunea" | DONE | 2026-06-22 | 4 stories (2 valuri, 2 echipe paralel + restore dupa clobber de merge). US-001 `GET /v1/ping` readiness (`account_id/mediu/autentificat_cu_cheie/are_creds_rar/ts`) + `GET /v1/integrare/postman.json` (v2.1.0, allowlist 3 rute). US-002 `integrare_examples.py` pur (7 limbaje × 2 canale, drift-test `is_required()`). US-003 tab "Integrare" IA pe 2 niveluri (limbaj→canal, VFP cu dialecte MSXML2/WinHttp), copy din `
`, empty-state CTA, export `.cardlink`, doar tokens. US-004 `POST /integrare/test-cheie` (`account_for_key` direct, scoped sesiune, no-echo). VERIFY: 568 teste + E2E browser (Playwright: VFP 3 niveluri comuta, htmx test-cheie → fragment eroare, 0 erori consola) + enqueue live; live RAR neprobat (lipsa creds key). `/code-review` high: 4 bug-uri reale reparate (C#/VFP snippet JSON multi-linie nevalid, Node `node:buffer` fara FormData, script ne-scoped acumuland listeneri). Backend trimitere NEATINS. PRD: [prd-5.1](prd/prd-5.1-hub-integrare.md) |
-| 5.2 | Endpoint dry-run `POST /v1/prezentari/valideaza` — valideaza payload + mapare, intoarce erorile reale FARA enqueue | TODO | | "magical moment" pt integratori; refoloseste `validation.py`+`mapping.py`, NU atinge coada |
+| 5.2 | Endpoint dry-run `POST /v1/prezentari/valideaza` — valideaza payload + mapare, intoarce erorile reale FARA enqueue | DONE | 2026-06-22 | 1 story (US-001), un worker TDD. Helper pur partajat `classify_prezentare` (mapping.py) folosit de AMBELE rute → garanteaza ca dry-run-ul da acelasi verdict ca trimiterea reala (invariant de corectitudine, nu doar DRY); `create_prezentari` refactorizat pe el (comportament identic, test_api.py verde). Ruta read-only: `{results:[{index,valid,status_estimat,erori,nemapate,prestatii_rezolvate}]}`, `rar_credentials` optional+ignorat, zero scriere DB, scope prin `resolve_account_id`. Doar validare+mapare (FARA idempotency/duplicat — decizie user; `idempotency.py` neatins) + hub `/integrare` amanat. VERIFY context curat PASS (577 teste; E2E API: queued/needs_data/needs_mapping + COUNT(*)=0 dupa dry-run, fara leak creds; regresia de aur `POST /v1/prezentari`→queued verde; live RAR neprobat — lipsa `AUTOPASS_CREDS_KEY`/creds test, endpoint read-only nu atinge worker/coada). `/code-review` high: 0 findings. PRD: [prd-5.2](prd/prd-5.2-dryrun-valideaza.md) |
 | 5.3 | Light/Dark mode — toggle in header, persistat (cookie/localStorage); CSS deja pe variabile `:root` | TODO | | efort mic, cerut explicit |
 | 5.4 | Erori pe 3 niveluri (problema + cauza + fix) pe API si UI | TODO | | DX: fight uncertainty; reduce suportul |
 
diff --git a/docs/prd/prd-5.2-dryrun-valideaza.md b/docs/prd/prd-5.2-dryrun-valideaza.md
new file mode 100644
index 0000000..bcff92d
--- /dev/null
+++ b/docs/prd/prd-5.2-dryrun-valideaza.md
@@ -0,0 +1,159 @@
+# PRD 5.2 — Endpoint dry-run `POST /v1/prezentari/valideaza`
+
+**Stare**: inchis
+
+> Proces complet: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
+> Starea trece: `draft → aprobat → in-executie → verify-pass → inchis` (actualizata de lead).
+
+## 1. Obiectiv
+
+Un endpoint care valideaza un payload de prezentari **exact ca** `POST /v1/prezentari`, dar
+**fara enqueue si fara efecte secundare**: intoarce verdictul (queued / needs_data / needs_mapping),
+erorile reale `[{field, message}]` si codurile nemapate, ca integratorul (ROAAUTO / soft propriu /
+punte VFP) sa-si verifice integrarea inainte sa trimita ceva real. "Magical moment" de onboarding
+(Etapa 5 — ergonomie & integrare).
+
+**Invariant de corectitudine (motivul cheie de design):** dry-run-ul trebuie sa produca **acelasi
+verdict** pe care l-ar produce `POST /v1/prezentari` pe acelasi payload. Daca cele doua cai diverg,
+dry-run-ul minte — mai rau decat sa nu existe. De aceea logica de clasificare se **extrage intr-un
+helper pur partajat** folosit de AMBELE endpoint-uri (nu se duplica).
+
+## 2. Non-Goals (anti scope-creep)
+
+- **NU atinge coada `submissions`** — zero INSERT/UPDATE/DELETE; endpoint read-only (citeste doar
+  `operations_mapping` pentru rezolvarea codurilor, scoped pe cont).
+- **NU detectie duplicat / idempotency** — nu raporteaza `idempotency_key` si nu cauta submission-uri
+  identice existente (decizie utilizator 2026-06-22: doar validare + mapare). `idempotency.py` neatins.
+- **NU stocheaza si NU foloseste creds RAR** — `rar_credentials` devine optional si, daca e prezent,
+  e ignorat (nu se cripteaza, nu se logheaza, nu se trimite nicaieri).
+- **NU UI / hub** — documentarea in `/integrare` (5.1) + snippet-uri ramane follow-up (decizie
+  utilizator 2026-06-22). Acest PRD = strict endpoint + teste.
+- **NU atinge worker, masina de stari, schema, validation.py (regulile), nomenclator.**
+- **NU apel live la RAR** — validarea e 100% locala (replica regulilor RAR din `validation.py`).
+
+## 3. Stories atomice
+
+### US-001: Endpoint dry-run `POST /v1/prezentari/valideaza`
+**Ca** integrator (ROAAUTO / soft propriu / VFP) **vreau** sa trimit un payload de prezentari si sa
+primesc inapoi exact erorile + verdictul pe care le-ar produce trimiterea reala, **fara** sa enqueue
+ceva sau sa am nevoie de creds RAR, **pentru ca** sa-mi validez integrarea sigur, repetabil, inainte
+de prima trimitere reala.
+
+- **Depinde de**: —
+- **Fisiere**: `app/mapping.py` (helper pur nou `classify_prezentare`), `app/api/v1/router.py`
+  (ruta + refactor `create_prezentari` sa foloseasca helper-ul), `app/models.py` (modele request/response),
+  `tests/test_validare_dryrun.py` (~4 fisiere)
+- **Test intai (RED)**: `tests/test_validare_dryrun.py` —
+  - `test_payload_valid_returneaza_queued` (valid → `status_estimat="queued"`, `valid=True`, `erori=[]`)
+  - `test_vin_invalid_returneaza_needs_data` (VIN cu O/I/Q → `needs_data` + eroare pe `field="vin"`)
+  - `test_data_viitoare_needs_data` (data in viitor → `needs_data`)
+  - `test_cod_op_nemapat_returneaza_needs_mapping` (cod_op_service necunoscut → `needs_mapping` +
+    `nemapate=[{cod_op_service, denumire}]`)
+  - `test_mapare_existenta_rezolva_codul` (cu mapare salvata pe cont → cod_op_service rezolvat in
+    `prestatii_rezolvate`, `status_estimat="queued"`)
+  - `test_fara_creds_merge` (body FARA `rar_credentials` → 200)
+  - `test_nu_scrie_in_coada` (`COUNT(*)` submissions inainte == dupa; zero efecte secundare)
+  - `test_multi_prezentari_rezultate_per_index` (lista [valid, invalid] → 2 rezultate, `index` 0/1,
+    statusuri diferite)
+  - `test_shape_invalid_422` (prestatie fara `cod_prestatie` SI fara `cod_op_service` → 422 de shape,
+    ca endpoint-ul real; raspunsul NU contine echo de `input`/parola)
+- **Acceptance criteria**:
+  - [ ] `POST /v1/prezentari/valideaza` exista, accepta `{prezentari:[...], rar_credentials?}`,
+        intoarce `{results:[{index, valid, status_estimat, erori, nemapate, prestatii_rezolvate}]}`.
+  - [ ] `status_estimat` ∈ {`queued`, `needs_data`, `needs_mapping`} si coincide cu statusul pe care
+        `POST /v1/prezentari` l-ar atribui pe acelasi payload + aceeasi mapare de cont (verificat prin
+        helper partajat `classify_prezentare`, folosit de ambele rute).
+  - [ ] `valid == (status_estimat == "queued")`.
+  - [ ] `erori` = exact lista `validate_prezentare` (forma `[{field, message}]`), goala cand e curat.
+  - [ ] `nemapate` = `[{cod_op_service, denumire}]` cand exista coduri interne nerezolvate; altfel `[]`.
+  - [ ] `prestatii_rezolvate` arata codurile RAR rezolvate (cod_op_service mapat → cod_prestatie umplut).
+  - [ ] `rar_credentials` optional; daca lipseste → 200; daca e prezent → ignorat (necriptat, nelogat).
+  - [ ] Zero scriere in DB: `COUNT(*) FROM submissions` neschimbat dupa apel (read-only pe mapping).
+  - [ ] Scope pe cont prin `resolve_account_id` (ca endpoint-ul real): maparea folosita la rezolvare e
+        a contului cheii API (dev fara cheie → cont 1; prod → cheie obligatorie).
+  - [ ] `create_prezentari` (endpoint-ul real) ramane cu comportament identic — toate testele
+        existente `tests/test_api.py` raman verzi dupa refactor-ul catre helper-ul partajat.
+  - [ ] `python3 -m pytest -q` verde.
+- **Verificare E2E**: canal API — `POST /v1/prezentari/valideaza` pe instanta locala cu (a) payload
+  valid → `queued`, (b) VIN invalid → `needs_data` + mesaj real, (c) cod_op nemapat → `needs_mapping`;
+  apoi `COUNT(*)` submissions neschimbat. **Regresia de aur:** `POST /v1/prezentari` real → worker →
+  `FINALIZATA` la RAR test (neatins de endpoint-ul nou).
+
+## 4. Riscuri
+
+- **Divergenta dry-run vs. real** (risc principal) → mitigat prin helper pur partajat `classify_prezentare`
+  folosit de ambele rute + AC explicit + testele `test_api.py` care lock-uiesc calea reala dupa refactor.
+- **Refactor al caii reale** (`create_prezentari` adopta helper-ul) ar putea schimba subtil comportamentul
+  → mitigat: testele existente `test_api.py` sunt contractul; raman verzi = comportament identic. Helper-ul
+  intoarce exact aceleasi `status` + `rar_error` ca azi (queued / needs_data + erori JSON / needs_mapping +
+  unmapped JSON / needs_mapping + auto_send note).
+- **Scurgere de creds** prin noul model → mitigat: `rar_credentials` optional, ignorat, `repr=False`
+  pastrat; handler-ul global 422 din `main.py` deja dropeaza `input`/`ctx` (no-echo parola) — acoperit de
+  `test_shape_invalid_422`.
+- **Asteptare gresita: "valideaza" = duplicat-check** → documentat ca Non-Goal in raspuns/docstring;
+  fara `idempotency_key` in raspuns ca sa nu sugereze dedup.
+
+## 5. Intrebari deschise
+
+> Rezolvate cu utilizatorul la poarta de aprobare PRD (2026-06-22).
+
+- **Continut raspuns** — REZOLVAT: doar validare + mapare (fara idempotency/duplicat). [user 2026-06-22]
+- **Hub /integrare** — REZOLVAT: amanat; 5.2 = endpoint + teste. [user 2026-06-22]
+- `auto_send=0` pe un cod mapat: pe calea reala devine `needs_mapping` cu nota "review manual". Dry-run-ul
+  raporteaza acelasi `status_estimat="needs_mapping"` (consistenta cu real). Confirmat ca acceptabil —
+  fara camp separat pentru cazul auto_send (ar fi scope creep). [decizie de plan, acceptata implicit]
+
+## 6. Valuri de executie (graful de dependente)
+
+```
+Val 1: [US-001]   ← singur story, fara dependente. Un worker (sau lead direct, livrabila mica — ROADMAP §5.5).
+```
+
+Livrabila mica: poate rula fara `TeamCreate` (un singur worker Sonnet TDD), dar VERIFY in context curat
++ writeback raman obligatorii (ROADMAP §5.5).
+
+## 7. Review-uri de plan (aplicate inainte de cod — ROADMAP §5.3)
+
+**CEO (valoare/scope) — PASS.** Problema corecta, calea cea mai directa (reuse pur
+`validation.py`+`mapping.py`, zero logica de domeniu noua). Inversiune ("ce-l face sa esueze?"):
+divergenta dry-run vs. real — neutralizata prin helper-ul partajat `classify_prezentare` +
+lock-ul `test_api.py`. Scope minim corect; singura "expansiune" (extragerea clasificatorului) e
+ceruta de corectitudine, nu scope creep. **Risc flagat (deferare constienta):** descoperibilitate
+— un endpoint pe care integratorii nu-l gasesc nu-si livreaza valoarea pana cand un follow-up il
+expune in `/integrare`. Acceptat de utilizator (amanat); valoarea 5.2 se realizeaza la follow-up.
+
+**Eng (fezabilitate/teste) — PASS.** Fezabilitate triviala, fara atingere schema/worker/idempotenta.
+Lista de teste e completa (happy path + fiecare ramura de eroare + fara-creds + multi-prezentare cu
+index + shape-422 fara echo + aserctia critica zero-efecte `COUNT(*)` inainte==dupa). Singurul risc
+pe calea de aur = refactor-ul `create_prezentari` pe helper-ul partajat; contractul de blocare =
+suita `test_api.py` existenta (ramane verde = comportament identic). Read-only → fara logging nou
+(consistent cu GET-urile existente); in prod cere cheie prin `resolve_account_id` (fara suprafata noua de abuz).
+
+---
+
+## Raport VERIFY
+
+> Completat de subagentul verificator (context curat) in faza VERIFY — vezi ROADMAP §5.6.
+> PASS/FAIL per criteriu, cu dovezi (output pytest citat, E2E pe canal API). Lipseste pana la VERIFY.
+
+Verificator independent (context curat, rol qa-only), 2026-06-22. **VERDICT GLOBAL: PASS.**
+
+**1. Suita — PASS.** `python3 -m pytest -q` → 577 passed (226 warnings preexistente Starlette/templating).
+`pytest tests/test_validare_dryrun.py tests/test_api.py -q` → 14 passed (9 dry-run TDD + 5 regresia caii reale).
+
+**2. Acceptance criteria US-001 — toate PASS.** Endpoint exista cu raspunsul `{results:[{index,valid,
+status_estimat,erori,nemapate,prestatii_rezolvate}]}`. `status_estimat` coincide cu `POST /v1/prezentari`
+prin helper-ul partajat `classify_prezentare` apelat de AMBELE rute (`router.py` create + valideaza, acelasi
+`load_mapping_meta`). `valid == (status=="queued")`. `erori`=lista `validate_prezentare`. `nemapate`=
+`[{cod_op_service,denumire}]`. `rar_credentials` optional + ignorat (`encrypt_creds` absent din ruta;
+parola din body NU apare in raspuns). Zero scriere DB (COUNT(*) submissions=0 dupa 3 apeluri). Scope prin
+`resolve_account_id`. `create_prezentari` identic (test_api.py verde).
+
+**3. E2E canal API (TestClient + DB temp, verificare directa SQLite) — PASS.** (a) valid+creds → queued,
+valid=true, erori=[], fara leak creds; (b) VIN cu O/I/Q → needs_data + eroare reala pe `vin`; (c) cod_op
+nemapat → needs_mapping + nemapate populat. Dupa toate: `GET /v1/prezentari`=0, DB COUNT(*)=0.
+
+**4. Regresia de aur — PASS (live neprobat, conform asteptarii).** `POST /v1/prezentari` enqueue-aza corect
+(200, queued, COUNT(*)=1) + test_api.py verde. Flux live RAR (worker→FINALIZATA pe RAR test) NEPROBAT —
+lipsesc secretele in mediu (`AUTOPASS_CREDS_KEY` nesetat, fara creds RAR test/`--send`). NU e FAIL al 5.2:
+endpoint-ul nou e read-only, nu atinge worker/coada/schema. Documentat, nu inventat.
diff --git a/tests/test_validare_dryrun.py b/tests/test_validare_dryrun.py
new file mode 100644
index 0000000..e81dd10
--- /dev/null
+++ b/tests/test_validare_dryrun.py
@@ -0,0 +1,159 @@
+"""Teste TDD pentru POST /v1/prezentari/valideaza (dry-run, PRD 5.2 US-001)."""
+
+from __future__ import annotations
+
+import os
+import tempfile
+
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture()
+def client(monkeypatch):
+    tmp = tempfile.mkdtemp()
+    monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
+    from app.config import get_settings
+    get_settings.cache_clear()
+    from app.main import app
+    with TestClient(app) as c:
+        yield c
+    get_settings.cache_clear()
+
+
+# --- helpere ---
+
+def _prez(**over):
+    """Prezentare valida implicita."""
+    p = {
+        "vin": "WVWZZZ1KZAW000123",
+        "nr_inmatriculare": "B999TST",
+        "data_prestatie": "2026-06-15",
+        "odometru_final": "123456",
+        "prestatii": [{"cod_prestatie": "OE-1"}],
+    }
+    p.update(over)
+    return p
+
+
+def _body_v(prezentari=None, **over):
+    """Body pentru /valideaza — rar_credentials optional."""
+    if prezentari is None:
+        prezentari = [_prez(**over)]
+    return {"prezentari": prezentari}
+
+
+# --- teste ---
+
+def test_payload_valid_returneaza_queued(client):
+    r = client.post("/v1/prezentari/valideaza", json=_body_v())
+    assert r.status_code == 200
+    res = r.json()["results"][0]
+    assert res["valid"] is True
+    assert res["status_estimat"] == "queued"
+    assert res["erori"] == []
+    assert res["index"] == 0
+
+
+def test_vin_invalid_returneaza_needs_data(client):
+    # VIN cu O/I/Q interzisi
+    r = client.post("/v1/prezentari/valideaza", json=_body_v(vin="WVWZZZ1OZIQ45678"))
+    assert r.status_code == 200
+    res = r.json()["results"][0]
+    assert res["status_estimat"] == "needs_data"
+    assert res["valid"] is False
+    fields = [e["field"] for e in res["erori"]]
+    assert "vin" in fields
+
+
+def test_data_viitoare_needs_data(client):
+    r = client.post("/v1/prezentari/valideaza", json=_body_v(data_prestatie="2099-01-01"))
+    assert r.status_code == 200
+    res = r.json()["results"][0]
+    assert res["status_estimat"] == "needs_data"
+    assert res["valid"] is False
+    fields = [e["field"] for e in res["erori"]]
+    assert "data_prestatie" in fields
+
+
+def test_cod_op_nemapat_returneaza_needs_mapping(client):
+    prez = _prez()
+    prez["prestatii"] = [{"cod_op_service": "REP_MOTOR_NECUNOSCUT", "denumire": "Reparatie motor"}]
+    r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
+    assert r.status_code == 200
+    res = r.json()["results"][0]
+    assert res["status_estimat"] == "needs_mapping"
+    assert res["valid"] is False
+    assert len(res["nemapate"]) == 1
+    assert res["nemapate"][0]["cod_op_service"] == "REP_MOTOR_NECUNOSCUT"
+
+
+def test_mapare_existenta_rezolva_codul(client):
+    # Salveaza mapare op->cod
+    r_map = client.post("/v1/mapari", json={
+        "cod_op_service": "REP_MOTOR",
+        "cod_prestatie": "OE-1",
+        "auto_send": True,
+    })
+    assert r_map.status_code == 200
+
+    prez = _prez()
+    prez["prestatii"] = [{"cod_op_service": "REP_MOTOR", "denumire": "Reparatie motor"}]
+    r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
+    assert r.status_code == 200
+    res = r.json()["results"][0]
+    assert res["status_estimat"] == "queued"
+    assert res["valid"] is True
+    assert len(res["prestatii_rezolvate"]) == 1
+    assert res["prestatii_rezolvate"][0]["cod_prestatie"] == "OE-1"
+
+
+def test_fara_creds_merge(client):
+    # rar_credentials absent -> 200 (optional in schema)
+    r = client.post("/v1/prezentari/valideaza", json=_body_v())
+    assert r.status_code == 200
+
+
+def test_nu_scrie_in_coada(client):
+    # Verifica zero efecte secundare: COUNT(*) neschimbat
+    r_before = client.get("/v1/prezentari")
+    assert r_before.status_code == 200
+    nr_before = len(r_before.json()["submissions"])
+
+    client.post("/v1/prezentari/valideaza", json=_body_v())
+
+    r_after = client.get("/v1/prezentari")
+    assert r_after.status_code == 200
+    nr_after = len(r_after.json()["submissions"])
+
+    assert nr_after == nr_before
+
+
+def test_multi_prezentari_rezultate_per_index(client):
+    prezentari = [
+        _prez(),  # valid -> queued
+        _prez(vin="WVWZZZ1OZIQ45678"),  # VIN invalid -> needs_data
+    ]
+    r = client.post("/v1/prezentari/valideaza", json={"prezentari": prezentari})
+    assert r.status_code == 200
+    results = r.json()["results"]
+    assert len(results) == 2
+    # index corect per pozitie
+    assert results[0]["index"] == 0
+    assert results[1]["index"] == 1
+    # statusuri diferite
+    assert results[0]["status_estimat"] == "queued"
+    assert results[1]["status_estimat"] == "needs_data"
+
+
+def test_shape_invalid_422(client):
+    # PrestatieItem fara cod_prestatie si fara cod_op_service -> 422 de shape Pydantic
+    prez = _prez()
+    prez["prestatii"] = [{"denumire": "ceva"}]  # lipseste cod_prestatie si cod_op_service
+    r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
+    assert r.status_code == 422
+    # Handlerul global dropeaza input/ctx — fara echo parola (desi creds lipseste, testam structura)
+    body = r.json()
+    for err in body.get("detail", []):
+        assert "input" not in err
+        assert "ctx" not in err