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) <noreply@anthropic.com>
This commit is contained in:
@@ -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".
|
- status mereu "FINALIZATA".
|
||||||
- tipPrestatie NU se trimite (server-generated GENERIC).
|
- 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").
|
- sistemReparat trimis mereu (default "null").
|
||||||
- prestatii: [{codPrestatie, idPrezentare: null}].
|
- prestatii: [{codPrestatie, idPrezentare: null}].
|
||||||
- b64Image / odometruInitial optionale (se omit daca lipsesc).
|
- obs / b64Image optionale — se OMIT din payload daca lipsesc.
|
||||||
T4 adauga snapshot-test fata de exemplul oficial din contract.
|
|
||||||
|
Snapshot test fata de exemplul oficial: tests/test_payload.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,25 +16,41 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
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]:
|
def build_rar_payload(prezentare: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Mapeaza o prezentare interna -> payload exact pentru RAR postPrezentare."""
|
"""Mapeaza o prezentare interna (snake_case) -> payload exact pentru RAR postPrezentare."""
|
||||||
prestatii = prezentare.get("prestatii") or []
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"vin": (prezentare.get("vin") or "").strip().upper(),
|
"vin": _upper(prezentare.get("vin")),
|
||||||
"nrInmatriculare": (prezentare.get("nr_inmatriculare") or "").strip().upper(),
|
"nrInmatriculare": _upper(prezentare.get("nr_inmatriculare")),
|
||||||
"dataPrestatie": prezentare.get("data_prestatie"),
|
"dataPrestatie": str(prezentare.get("data_prestatie") or "").strip(),
|
||||||
"odometruFinal": str(prezentare.get("odometru_final") or "").strip(),
|
# odometruFinal ramane string (nu folosim `or` ca sa nu pierdem "0").
|
||||||
"odometruInitial": prezentare.get("odometru_initial"),
|
"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": [
|
"prestatii": [
|
||||||
{
|
{"codPrestatie": _cod(p), "idPrezentare": None}
|
||||||
"codPrestatie": (p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None)),
|
for p in (prezentare.get("prestatii") or [])
|
||||||
"idPrezentare": None,
|
|
||||||
}
|
|
||||||
for p in prestatii
|
|
||||||
],
|
],
|
||||||
"sistemReparat": prezentare.get("sistem_reparat") or "null",
|
"sistemReparat": prezentare.get("sistem_reparat") or "null",
|
||||||
"status": "FINALIZATA",
|
"status": "FINALIZATA",
|
||||||
}
|
}
|
||||||
|
# tipPrestatie: NICIODATA in payload (server-generated GENERIC).
|
||||||
if prezentare.get("obs"):
|
if prezentare.get("obs"):
|
||||||
payload["obs"] = prezentare["obs"]
|
payload["obs"] = prezentare["obs"]
|
||||||
if prezentare.get("b64_image"):
|
if prezentare.get("b64_image"):
|
||||||
|
|||||||
@@ -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
|
[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.
|
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).
|
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.
|
- [x] **T4 (P1) — payload builder** ✅ 2026-06-15. `app/payload.py`: `status:"FINALIZATA"`, `sistemReparat:"null"`, fără
|
||||||
Verify: snapshot payload == exemplul oficial din contract.
|
`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.
|
- [ ] **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.
|
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.
|
- [ ] **T6 (P2) — worker proces/container propriu supravegheat;** `/healthz` pică → restart. Verify: worker omorât → restart automat.
|
||||||
|
|||||||
102
tests/test_payload.py
Normal file
102
tests/test_payload.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user