From 36d1b916d5ca79257c113165fb72bf3910dc12a5 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 15 Jun 2026 17:28:33 +0000 Subject: [PATCH] feat(T4): payload builder finalizat + snapshot test - app/payload.py rafinat: odometruFinal/odometruInitial string (initial gol -> null), evita capcana falsy `or ""` (pastreaza "0"), normalizare vin/nrInm/coduri, tipPrestatie niciodata trimis, obs/b64Image omise cand lipsesc - tests/test_payload.py: 10 teste, inclusiv snapshot vs exemplul oficial din contract Verify: pytest 39 passed (29 + 10). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/payload.py | 51 ++++++++++++++------- docs/plans/plan.md | 5 ++- tests/test_payload.py | 102 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 19 deletions(-) create mode 100644 tests/test_payload.py diff --git a/app/payload.py b/app/payload.py index ffff859..5774c67 100644 --- a/app/payload.py +++ b/app/payload.py @@ -1,13 +1,14 @@ -"""Constructor payload postPrezentare (schelet — T4 il completeaza). +"""Constructor payload postPrezentare (T4). -Reguli din contract (docs/api-rar-contract.md): +Reguli din contract (docs/api-rar-contract.md), confirmate live in T1: - status mereu "FINALIZATA". - tipPrestatie NU se trimite (server-generated GENERIC). - - odometruFinal ca string. + - odometruFinal ca string; odometruInitial ca string cand e prezent, altfel null. - sistemReparat trimis mereu (default "null"). - prestatii: [{codPrestatie, idPrezentare: null}]. - - b64Image / odometruInitial optionale (se omit daca lipsesc). -T4 adauga snapshot-test fata de exemplul oficial din contract. + - obs / b64Image optionale — se OMIT din payload daca lipsesc. + +Snapshot test fata de exemplul oficial: tests/test_payload.py. """ from __future__ import annotations @@ -15,25 +16,41 @@ from __future__ import annotations from typing import Any +def _upper(value: object) -> str: + return str(value or "").strip().upper() + + +def _str_or_none(value: object) -> str | None: + """str.strip() pentru valori non-goale; None pentru None / string gol.""" + if value is None: + return None + s = str(value).strip() + return s or None + + +def _cod(p: object) -> str | None: + cod = p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None) + return str(cod).strip().upper() if cod else None + + def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]: - """Mapeaza o prezentare interna -> payload exact pentru RAR postPrezentare.""" - prestatii = prezentare.get("prestatii") or [] + """Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare.""" payload: dict[str, Any] = { - "vin": (prezentare.get("vin") or "").strip().upper(), - "nrInmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(), - "dataPrestatie": prezentare.get("data_prestatie"), - "odometruFinal": str(prezentare.get("odometru_final") or "").strip(), - "odometruInitial": prezentare.get("odometru_initial"), + "vin": _upper(prezentare.get("vin")), + "nrInmatriculare": _upper(prezentare.get("nr_inmatriculare")), + "dataPrestatie": str(prezentare.get("data_prestatie") or "").strip(), + # odometruFinal ramane string (nu folosim `or` ca sa nu pierdem "0"). + "odometruFinal": str(prezentare.get("odometru_final")).strip() + if prezentare.get("odometru_final") is not None else "", + "odometruInitial": _str_or_none(prezentare.get("odometru_initial")), "prestatii": [ - { - "codPrestatie": (p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None)), - "idPrezentare": None, - } - for p in prestatii + {"codPrestatie": _cod(p), "idPrezentare": None} + for p in (prezentare.get("prestatii") or []) ], "sistemReparat": prezentare.get("sistem_reparat") or "null", "status": "FINALIZATA", } + # tipPrestatie: NICIODATA in payload (server-generated GENERIC). if prezentare.get("obs"): payload["obs"] = prezentare["obs"] if prezentare.get("b64_image"): diff --git a/docs/plans/plan.md b/docs/plans/plan.md index 1ed4e89..c89742c 100644 --- a/docs/plans/plan.md +++ b/docs/plans/plan.md @@ -199,8 +199,9 @@ Nimic din cod nu e scris încă (`app/`, `tools/` nu există). Ordine recomandat [2024-12-01,azi] TZ Bucharest, R-ODO/I-ODO→odometruInitial, `odometruInitial<=odometruFinal`) + normalize strip/upper în modelele Pydantic. **Eșecurile de conținut → `needs_data` (ținute, nu 422)** per masina de stări; JSON malformat → 422. Verify: 29 teste pass (`tests/test_validation.py` per regulă + `tests/test_api.py` rutare/idempotenta). -- [ ] **T4 (P1) — payload builder** cu `status:"FINALIZATA"`, `sistemReparat:"null"`, fără `tipPrestatie`, `odometruFinal` string. - Verify: snapshot payload == exemplul oficial din contract. +- [x] **T4 (P1) — payload builder** ✅ 2026-06-15. `app/payload.py`: `status:"FINALIZATA"`, `sistemReparat:"null"`, fără + `tipPrestatie`, `odometruFinal`/`odometruInitial` string (initial gol → null), `prestatii:[{codPrestatie,idPrezentare:null}]`, + obs/b64Image omise când lipsesc. Verify: 10 teste (`tests/test_payload.py`), inclusiv snapshot vs exemplul oficial din contract. - [ ] **T2 (P1) — `app/worker` reconciliere** VIN+dată+odometru înainte de re-send pe `sending` + lease/timeout orfane. Verify: test integration — răspuns pierdut simulat → fără duplicat la RAR. - [ ] **T6 (P2) — worker proces/container propriu supravegheat;** `/healthz` pică → restart. Verify: worker omorât → restart automat. diff --git a/tests/test_payload.py b/tests/test_payload.py new file mode 100644 index 0000000..0e56109 --- /dev/null +++ b/tests/test_payload.py @@ -0,0 +1,102 @@ +"""Teste T4 — payload builder (app.payload.build_rar_payload). + +Snapshot fata de exemplul oficial de request din docs/api-rar-contract.md + +invariantii din contract (status FINALIZATA, fara tipPrestatie, odometru string, +idPrezentare null, omitere obs/b64Image, normalizare). +""" + +from __future__ import annotations + +from app.models import PrezentareIn +from app.payload import build_rar_payload + + +def _internal(**over) -> dict: + data = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + data.update(over) + return PrezentareIn(**data).model_dump() + + +def test_snapshot_exemplul_oficial_din_contract(): + """Payload == exemplul oficial postPrezentare (request) din contract.""" + internal = { + "vin": "XXXXXXXXXXXXXXXXX", + "nr_inmatriculare": "B999GEN", + "data_prestatie": "2024-12-01", # exemplul are 2024-07-25, dar acela e < min; folosim o data valida, restul identic + "odometru_final": "9999999", + "odometru_initial": None, + "prestatii": [{"cod_prestatie": "OE-1"}, {"cod_prestatie": "OE-2"}], + "sistem_reparat": "null", + "obs": "TEST", + "b64_image": "UklGRg==", + } + expected = { + "vin": "XXXXXXXXXXXXXXXXX", + "nrInmatriculare": "B999GEN", + "dataPrestatie": "2024-12-01", + "odometruFinal": "9999999", + "odometruInitial": None, + "prestatii": [ + {"codPrestatie": "OE-1", "idPrezentare": None}, + {"codPrestatie": "OE-2", "idPrezentare": None}, + ], + "sistemReparat": "null", + "status": "FINALIZATA", + "obs": "TEST", + "b64Image": "UklGRg==", + } + assert build_rar_payload(PrezentareIn(**internal).model_dump()) == expected + + +def test_status_mereu_finalizata(): + assert build_rar_payload(_internal())["status"] == "FINALIZATA" + + +def test_nu_trimite_tipprestatie(): + assert "tipPrestatie" not in build_rar_payload(_internal()) + + +def test_odometru_final_este_string(): + p = build_rar_payload(_internal(odometru_final="55000")) + assert p["odometruFinal"] == "55000" + assert isinstance(p["odometruFinal"], str) + + +def test_omite_obs_si_b64_cand_lipsesc(): + p = build_rar_payload(_internal()) + assert "obs" not in p + assert "b64Image" not in p + + +def test_odometru_initial_gol_devine_null(): + assert build_rar_payload(_internal(odometru_initial=""))["odometruInitial"] is None + assert build_rar_payload(_internal())["odometruInitial"] is None + + +def test_odometru_initial_prezent_string(): + p = build_rar_payload(_internal(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="100000")) + assert p["odometruInitial"] == "100000" + + +def test_prestatii_au_idprezentare_null(): + p = build_rar_payload(_internal(prestatii=[{"cod_prestatie": "OE-1"}, {"cod_prestatie": "OE-3"}])) + assert p["prestatii"] == [ + {"codPrestatie": "OE-1", "idPrezentare": None}, + {"codPrestatie": "OE-3", "idPrezentare": None}, + ] + + +def test_normalizare_vin_nrinm_in_payload(): + p = build_rar_payload(_internal(vin=" wvwzzz1kzaw000123 ", nr_inmatriculare=" b999tst ")) + assert p["vin"] == "WVWZZZ1KZAW000123" + assert p["nrInmatriculare"] == "B999TST" + + +def test_sistem_reparat_default_null(): + assert build_rar_payload(_internal())["sistemReparat"] == "null"