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) <noreply@anthropic.com>
This commit is contained in:
@@ -22,20 +22,25 @@ from pydantic import BaseModel, Field
|
|||||||
from ...auth import resolve_account_id
|
from ...auth import resolve_account_id
|
||||||
from ...crypto import encrypt_creds
|
from ...crypto import encrypt_creds
|
||||||
from ...db import get_connection
|
from ...db import get_connection
|
||||||
from ...idempotency import build_key, canonicalize_row, idempotency_key
|
from ...idempotency import build_key, canonicalize_row
|
||||||
from ...mapping import (
|
from ...mapping import (
|
||||||
account_or_default,
|
account_or_default,
|
||||||
account_scope_clause,
|
account_scope_clause,
|
||||||
has_no_auto_send,
|
classify_prezentare,
|
||||||
load_mapping_meta,
|
load_mapping_meta,
|
||||||
pending_unmapped,
|
pending_unmapped,
|
||||||
reresolve_account,
|
reresolve_account,
|
||||||
resolve_prestatii,
|
|
||||||
save_mapping,
|
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 ...payload_view import prezentare_din_payload
|
||||||
from ...validation import validate_prezentare
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||||
|
|
||||||
@@ -94,43 +99,53 @@ def create_prezentari(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Mapare op->cod RAR (hibrid): codul RAR direct trece neatins; codul
|
# Helper pur partajat cu dry-run (PRD 5.2): reproduce EXACT clasificarea
|
||||||
# intern ROAAUTO se traduce. Op nemapata -> needs_mapping (nu se trimite),
|
# (canonicalize + mapare op->cod + validare + auto_send gate).
|
||||||
# apare in editorul web. Codul rezolvat se scrie inapoi in payload, deci
|
cl = classify_prezentare(content, mapping, mapping_meta)
|
||||||
# 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
|
|
||||||
|
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
|
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, rar_creds_enc) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return PrezentariResponse(results=results)
|
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")
|
@router.get("/prezentari")
|
||||||
def list_prezentari(
|
def list_prezentari(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
|||||||
@@ -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:
|
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.
|
"""Verifica daca vreun item rezolvat via mapping are auto_send=0.
|
||||||
|
|
||||||
|
|||||||
@@ -95,3 +95,25 @@ class SubmissionResult(BaseModel):
|
|||||||
|
|
||||||
class PrezentariResponse(BaseModel):
|
class PrezentariResponse(BaseModel):
|
||||||
results: list[SubmissionResult]
|
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]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
159
docs/prd/prd-5.2-dryrun-valideaza.md
Normal file
159
docs/prd/prd-5.2-dryrun-valideaza.md
Normal file
@@ -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.
|
||||||
159
tests/test_validare_dryrun.py
Normal file
159
tests/test_validare_dryrun.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user