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 `
`, 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