From 2117ab5c1ebadbc60d268fd441a0d0d92dec29f9 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 15 Jun 2026 13:49:20 +0000 Subject: [PATCH] feat(T3): validare completa prezentari + 29 teste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/validation.py: reguli de continut (VIN ^[A-HJ-NPR-Z0-9]{17}$ fara O/I/Q, nrInm ^[A-Z0-9]{1,10}$, dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti, R-ODO/I-ODO -> odometruInitial obligatoriu, odometruInitial<=odometruFinal, odometruFinal numeric, prestatii nevide, b64Image base64 valid) - erori structurate {field, message} (aceeasi forma ca raspunsul RAR), fara exceptii - modele Pydantic: normalizare strip/upper pe vin/nrInm/coduri - router /v1/prezentari: validare inainte de enqueue; esec continut -> needs_data (tinut, vizibil in dashboard cu motiv), NU 422; JSON malformat -> 422 (shape) - tests/: 29 teste (per regula + rutare API + idempotenta) Verify: pytest 29 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/router.py | 22 +++++-- app/models.py | 27 +++++++- app/validation.py | 136 ++++++++++++++++++++++++++++++++++++++ docs/plans/plan.md | 6 +- tests/__init__.py | 0 tests/test_api.py | 71 ++++++++++++++++++++ tests/test_validation.py | 139 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 app/validation.py create mode 100644 tests/__init__.py create mode 100644 tests/test_api.py create mode 100644 tests/test_validation.py diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 485018c..cb56f7b 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException from ...db import get_connection from ...idempotency import idempotency_key from ...models import PrezentareRequest, PrezentariResponse, SubmissionResult +from ...validation import validate_prezentare router = APIRouter(prefix="/v1", tags=["v1"]) @@ -26,8 +27,9 @@ router = APIRouter(prefix="/v1", tags=["v1"]) def create_prezentari(req: PrezentareRequest) -> PrezentariResponse: """Enqueue una/mai multe prezentari. Idempotent: continut identic -> acelasi submission. - TODO(T3): validare Pydantic completa inainte de enqueue (VIN/data/nrInm), - ruteaza needs_data/needs_mapping. + Validarea de continut (T3, app.validation) ruleaza inainte de enqueue: + esecurile NU resping cererea, ci enqueue-aza cu status `needs_data` + motiv + (plan.md sect. 3). JSON malformat -> 422 din Pydantic (validare de shape). TODO(auth): rezolva account_id din API key (acum None). Nota: rar_credentials NU se persista (zero-storage) — worker-ul le va primi pe alt canal (T2); in schelet enqueue-ul doar stocheaza prezentarea. @@ -53,12 +55,20 @@ def create_prezentari(req: PrezentareRequest) -> PrezentariResponse: ) ) continue + + # 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) + else: + status, rar_error = "queued", None + cur = conn.execute( - "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " - "VALUES (?, ?, 'queued', ?)", - (key, account_id, json.dumps(content, ensure_ascii=False)), + "INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) " + "VALUES (?, ?, ?, ?, ?)", + (key, account_id, status, json.dumps(content, ensure_ascii=False), rar_error), ) - results.append(SubmissionResult(submission_id=int(cur.lastrowid), status="queued")) + results.append(SubmissionResult(submission_id=int(cur.lastrowid), status=status)) finally: conn.close() return PrezentariResponse(results=results) diff --git a/app/models.py b/app/models.py index 80f6e0b..baf72cd 100644 --- a/app/models.py +++ b/app/models.py @@ -8,7 +8,7 @@ obligatoriu, odometruInitial <= odometruFinal, normalizare strip/upper) este from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class RarCredentials(BaseModel): @@ -21,13 +21,24 @@ class RarCredentials(BaseModel): class PrestatieItem(BaseModel): cod_prestatie: str = Field(..., description="cod din nomenclator RAR, ex. OE-1") + @field_validator("cod_prestatie") + @classmethod + def _norm_cod(cls, v: str) -> str: + return v.strip().upper() + class PrezentareIn(BaseModel): - """O prezentare de declarat la RAR (inainte de validarea T3).""" + """O prezentare de declarat la RAR. + + Pydantic doar NORMALIZEAZA aici (strip/upper pe vin/nrInm). Validarea de + continut (regex VIN, interval data, R-ODO/I-ODO, odometru) e in + app.validation.validate_prezentare si NU resping cererea — marcheaza + `needs_data` (plan.md sect. 3). + """ vin: str nr_inmatriculare: str - data_prestatie: str # YYYY-MM-DD; validare interval = T3 + data_prestatie: str # YYYY-MM-DD odometru_final: str # string per contract odometru_initial: str | None = None prestatii: list[PrestatieItem] @@ -35,6 +46,16 @@ class PrezentareIn(BaseModel): obs: str | None = None b64_image: str | None = None + @field_validator("vin", "nr_inmatriculare") + @classmethod + def _norm_upper(cls, v: str) -> str: + return v.strip().upper() + + @field_validator("data_prestatie", "odometru_final") + @classmethod + def _norm_strip(cls, v: str) -> str: + return v.strip() + class PrezentareRequest(BaseModel): """Body pentru POST /v1/prezentari — una sau mai multe prezentari + creds RAR.""" diff --git a/app/validation.py b/app/validation.py new file mode 100644 index 0000000..83da623 --- /dev/null +++ b/app/validation.py @@ -0,0 +1,136 @@ +"""Validare de domeniu pentru prezentari (T3). + +Replica regulile RAR (docs/api-rar-contract.md sect. "Reguli de validare") ÎNAINTE +de enqueue, ca sa nu primim 4xx de la RAR. Spre deosebire de validarea de SHAPE +(Pydantic, da 422 la JSON malformat), aceasta e validare de CONTINUT: esecurile NU +resping cererea, ci marcheaza submission-ul `needs_data` (plan.md sect. 3 — masina de +stari + failure registry). Asa apar in dashboard cu motiv, corectabile. + +Functiile sunt pure (dict -> listă erori), unit-testabile fara DB/HTTP. +Erorile au forma {field, message} — aceeasi ca raspunsul de eroare RAR. +""" + +from __future__ import annotations + +import base64 +import re +from datetime import date +from zoneinfo import ZoneInfo + +# VIN: 17 caractere, majuscule, fara O/I/Q (plan §2 + contract). +VIN_RE = re.compile(r"^[A-HJ-NPR-Z0-9]{17}$") +# Numar inmatriculare: max 10, litere + cifre majuscule. +NRINM_RE = re.compile(r"^[A-Z0-9]{1,10}$") +# Coduri care fac odometruInitial obligatoriu. +ODOMETER_CODES = {"R-ODO", "I-ODO"} +# Interval dataPrestatie. +MIN_DATA_PRESTATIE = date(2024, 12, 1) +TZ_BUCURESTI = ZoneInfo("Europe/Bucharest") + + +def _norm(value: object) -> str: + return str(value or "").strip().upper() + + +def _codes(prestatii: list | None) -> list[str]: + out: list[str] = [] + for p in prestatii or []: + cod = p.get("cod_prestatie") if isinstance(p, dict) else getattr(p, "cod_prestatie", None) + if cod: + out.append(str(cod).strip().upper()) + return out + + +def _parse_int(value: object) -> int | None: + s = str(value or "").strip() + if s.isdigit(): + return int(s) + return None + + +def today_bucuresti() -> date: + from datetime import datetime + + return datetime.now(TZ_BUCURESTI).date() + + +def validate_prezentare(content: dict) -> list[dict]: + """Intoarce lista erorilor de continut [{field, message}]. Goala = valid. + + `content` = PrezentareIn.model_dump() (campuri snake_case interne). + """ + errors: list[dict] = [] + + # --- VIN --- + vin = _norm(content.get("vin")) + if not VIN_RE.match(vin): + errors.append({ + "field": "vin", + "message": "VIN trebuie sa aiba exact 17 caractere majuscule, fara spatii/caractere speciale si fara O, I, Q.", + }) + + # --- nrInmatriculare --- + nrinm = _norm(content.get("nr_inmatriculare")) + if not NRINM_RE.match(nrinm): + errors.append({ + "field": "nr_inmatriculare", + "message": "Numarul de inmatriculare trebuie sa aiba max 10 caractere, doar litere si cifre majuscule.", + }) + + # --- dataPrestatie ∈ [2024-12-01, azi] TZ Bucuresti --- + raw_data = str(content.get("data_prestatie") or "").strip() + try: + d = date.fromisoformat(raw_data) + except ValueError: + errors.append({"field": "data_prestatie", "message": "Format data invalid; foloseste YYYY-MM-DD."}) + d = None + if d is not None: + if d < MIN_DATA_PRESTATIE: + errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi anterioara datei de 01.12.2024."}) + elif d > today_bucuresti(): + errors.append({"field": "data_prestatie", "message": "Data prestatiei nu poate fi in viitor."}) + + # --- odometruFinal (string numeric) --- + odo_final = _parse_int(content.get("odometru_final")) + if odo_final is None: + errors.append({"field": "odometru_final", "message": "odometruFinal trebuie sa fie un numar intreg (ca string)."}) + + # --- odometruInitial: obligatoriu daca prestatii ∋ R-ODO/I-ODO; <= odometruFinal --- + codes = _codes(content.get("prestatii")) + needs_initial = bool(set(codes) & ODOMETER_CODES) + raw_initial = content.get("odometru_initial") + has_initial = str(raw_initial or "").strip() != "" + if needs_initial and not has_initial: + errors.append({ + "field": "odometru_initial", + "message": "odometruInitial este obligatoriu cand prestatiile contin R-ODO sau I-ODO.", + }) + if has_initial: + odo_initial = _parse_int(raw_initial) + if odo_initial is None: + errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie un numar intreg."}) + elif odo_final is not None and odo_initial > odo_final: + errors.append({"field": "odometru_initial", "message": "odometruInitial trebuie sa fie <= odometruFinal."}) + + # --- prestatii nevide --- + if not codes: + errors.append({"field": "prestatii", "message": "Lista de prestatii nu poate fi goala."}) + + # --- b64Image: optional, dar daca e prezent trebuie base64 valid --- + b64 = content.get("b64_image") + if b64: + if not _is_valid_base64(str(b64)): + errors.append({"field": "b64_image", "message": "b64Image nu este base64 valid."}) + + return errors + + +def _is_valid_base64(value: str) -> bool: + s = value.strip() + if not s: + return False + try: + base64.b64decode(s, validate=True) + return True + except (ValueError, base64.binascii.Error): # type: ignore[attr-defined] + return False diff --git a/docs/plans/plan.md b/docs/plans/plan.md index 461eb90..1ed4e89 100644 --- a/docs/plans/plan.md +++ b/docs/plans/plan.md @@ -195,8 +195,10 @@ Nimic din cod nu e scris încă (`app/`, `tools/` nu există). Ordine recomandat - [x] **Schelet repo** — ✅ 2026-06-15. `app/api/v1`, `app/rar_client.py` (cu User-Agent), `app/worker`, `app/web`, SQLite (WAL), `Dockerfile` + `docker compose`, `/healthz` verde. Verificat: login prin client OK, nomenclator 18 coduri, worker heartbeat → `worker_alive=True`, enqueue + dedup idempotency funcționale. -- [ ] **T3 (P1) — validare Pydantic completă** (VIN `^[A-HJ-NPR-Z0-9]{17}$`, nrInm, dată ∈ [2024-12-01,azi] TZ Bucharest, - R-ODO/I-ODO→odometruInitial, `odometruInitial<=odometruFinal`, normalize strip/upper). Verify: unit tests per regulă. +- [x] **T3 (P1) — validare completă** ✅ 2026-06-15. `app/validation.py` (VIN `^[A-HJ-NPR-Z0-9]{17}$`, nrInm, dată ∈ + [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. - [ ] **T2 (P1) — `app/worker` reconciliere** VIN+dată+odometru înainte de re-send pe `sending` + lease/timeout orfane. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2bff00f --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,71 @@ +"""Teste API /v1/prezentari — rutare validare (T3) + idempotenta.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + # DB temporara izolata per test. + tmp = tempfile.mkdtemp() + monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db")) + from app.config import get_settings + get_settings.cache_clear() # reincarca settings cu noul env + from app.main import app + with TestClient(app) as c: + yield c + get_settings.cache_clear() + + +def _body(**over): + prez = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "prestatii": [{"cod_prestatie": "OE-1"}], + } + prez.update(over) + return {"rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [prez]} + + +def test_prezentare_valida_queued(client): + r = client.post("/v1/prezentari", json=_body()) + assert r.status_code == 200 + assert r.json()["results"][0]["status"] == "queued" + + +def test_vin_invalid_needs_data(client): + r = client.post("/v1/prezentari", json=_body(vin="WVWZZZ1OZIQ45678")) + assert r.status_code == 200 + sid = r.json()["results"][0] + assert sid["status"] == "needs_data" + + +def test_data_viitoare_needs_data(client): + r = client.post("/v1/prezentari", json=_body(data_prestatie="2099-01-01")) + assert r.json()["results"][0]["status"] == "needs_data" + + +def test_idempotenta_dedup(client): + b = _body() + r1 = client.post("/v1/prezentari", json=b) + r2 = client.post("/v1/prezentari", json=b) + id1 = r1.json()["results"][0]["submission_id"] + res2 = r2.json()["results"][0] + assert res2["submission_id"] == id1 + assert res2["deduped"] is True + + +def test_json_malformat_422(client): + # Lipseste vin -> validare de shape Pydantic -> 422 (NU needs_data). + bad = {"rar_credentials": {"email": "x", "password": "y"}, + "prezentari": [{"nr_inmatriculare": "B1", "data_prestatie": "2026-06-15", + "odometru_final": "1", "prestatii": [{"cod_prestatie": "OE-1"}]}]} + r = client.post("/v1/prezentari", json=bad) + assert r.status_code == 422 diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..5a66f5c --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,139 @@ +"""Teste T3 — validare de domeniu prezentari (app.validation). + +Un test per regula din plan.md sect. 2 / contract. Validarea normalizeaza +(strip/upper) si intoarce erori structurate {field, message}; nu ridica exceptii. +""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from app.models import PrezentareIn +from app.validation import today_bucuresti, validate_prezentare + + +def _base(**overrides) -> dict: + """Prezentare valida ca dict (normalizat prin PrezentareIn), cu suprascrieri.""" + data = { + "vin": "WVWZZZ1KZAW000123", + "nr_inmatriculare": "B999TST", + "data_prestatie": "2026-06-15", + "odometru_final": "123456", + "odometru_initial": None, + "prestatii": [{"cod_prestatie": "OE-1"}], + "sistem_reparat": "null", + } + data.update(overrides) + return PrezentareIn(**data).model_dump() + + +def _fields(errors: list[dict]) -> set[str]: + return {e["field"] for e in errors} + + +def test_prezentare_valida_fara_erori(): + assert validate_prezentare(_base()) == [] + + +def test_normalizare_strip_upper(): + c = _base(vin=" wvwzzz1kzaw000123 ", nr_inmatriculare=" b999tst ") + assert c["vin"] == "WVWZZZ1KZAW000123" + assert c["nr_inmatriculare"] == "B999TST" + assert validate_prezentare(c) == [] + + +@pytest.mark.parametrize("vin", [ + "WVWZZ1KZAW000123", # 16 caractere + "WVWZZZ1KZAW0001234", # 18 caractere + "WVWZZZ1OZAW000123", # contine O + "WVWZZZ1IZAW000123", # contine I + "WVWZZZ1QZAW000123", # contine Q + "WVW ZZ1KZAW00012", # spatiu +]) +def test_vin_invalid(vin): + errors = validate_prezentare(_base(vin=vin)) + assert "vin" in _fields(errors) + + +def test_nrinmatriculare_prea_lung(): + errors = validate_prezentare(_base(nr_inmatriculare="ABCDEFGHIJK")) # 11 + assert "nr_inmatriculare" in _fields(errors) + + +def test_nrinmatriculare_caracter_special(): + errors = validate_prezentare(_base(nr_inmatriculare="B-99")) + assert "nr_inmatriculare" in _fields(errors) + + +def test_data_prea_veche(): + errors = validate_prezentare(_base(data_prestatie="2024-11-30")) + assert "data_prestatie" in _fields(errors) + + +def test_data_la_limita_inferioara_ok(): + assert validate_prezentare(_base(data_prestatie="2024-12-01")) == [] + + +def test_data_in_viitor(): + maine = (today_bucuresti() + timedelta(days=1)).isoformat() + errors = validate_prezentare(_base(data_prestatie=maine)) + assert "data_prestatie" in _fields(errors) + + +def test_data_azi_ok(): + azi = today_bucuresti().isoformat() + assert validate_prezentare(_base(data_prestatie=azi)) == [] + + +def test_data_format_invalid(): + errors = validate_prezentare(_base(data_prestatie="15-06-2026")) + assert "data_prestatie" in _fields(errors) + + +def test_odometru_final_nenumeric(): + errors = validate_prezentare(_base(odometru_final="abc")) + assert "odometru_final" in _fields(errors) + + +def test_rodo_fara_odometru_initial(): + errors = validate_prezentare(_base(prestatii=[{"cod_prestatie": "R-ODO"}])) + assert "odometru_initial" in _fields(errors) + + +def test_iodo_fara_odometru_initial(): + errors = validate_prezentare(_base(prestatii=[{"cod_prestatie": "I-ODO"}])) + assert "odometru_initial" in _fields(errors) + + +def test_rodo_cu_odometru_initial_ok(): + c = _base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="100000") + assert validate_prezentare(c) == [] + + +def test_odometru_initial_mai_mare_decat_final(): + c = _base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000") + assert "odometru_initial" in _fields(errors := validate_prezentare(c)) + assert any("<=" in e["message"] for e in errors) + + +def test_prestatii_goale(): + errors = validate_prezentare(_base(prestatii=[])) + assert "prestatii" in _fields(errors) + + +def test_b64image_invalid(): + errors = validate_prezentare(_base(b64_image="@@@not-base64@@@")) + assert "b64_image" in _fields(errors) + + +def test_b64image_valid_ok(): + import base64 + good = base64.b64encode(b"poza odometru").decode() + assert validate_prezentare(_base(b64_image=good)) == [] + + +def test_erori_multiple_cumulate(): + errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01")) + assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)