"""Teste API import Treapta 2 — POST /v1/import, preview, commit, export-failed. Acopera: - #11 U1+T4: upload + staging + mapare coloane semnatura/drift/fuzzy - #12 T2+T11: preview 6 stari + already_sent batch lookup + intra-batch collision - #13 T5+T12: gate HARD confirmare + atestare valori + commit ON CONFLICT (TOCTOU) - #14 T8: export randuri esuate CSV """ from __future__ import annotations import csv import io import json import os import tempfile import openpyxl import pytest from fastapi.testclient import TestClient # --------------------------------------------------------------------------- # # Fixture client # # --------------------------------------------------------------------------- # @pytest.fixture() def client(monkeypatch): """Client FastAPI cu 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() from app.crypto import reset_cache reset_cache() from app.main import app with TestClient(app) as c: yield c get_settings.cache_clear() # --------------------------------------------------------------------------- # # Helpere pentru fisiere test # # --------------------------------------------------------------------------- # _HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"] _ROW_OK = ["WVWZZZ1KZAW000123", "B999TST", "2026-06-15", "123456", "Revizie"] _ROW_OK2 = ["WVWZZZ1KZAW000124", "CJ001AB", "2026-05-10", "98765", "Reparatie"] def _make_xlsx(rows: list[list]) -> bytes: """Creeaza un xlsx in-memory.""" wb = openpyxl.Workbook() ws = wb.active ws.title = "Sheet1" for row in rows: ws.append(row) buf = io.BytesIO() wb.save(buf) return buf.getvalue() def _make_csv(rows: list[list], delimiter: str = ";") -> bytes: """Creeaza un CSV in-memory.""" buf = io.StringIO() writer = csv.writer(buf, delimiter=delimiter) for row in rows: writer.writerow(row) return buf.getvalue().encode("utf-8") def _upload_file(client: TestClient, data: bytes, filename: str = "test.xlsx") -> dict: """Upload un fisier si intoarce raspunsul JSON.""" r = client.post( "/v1/import", files={"file": (filename, io.BytesIO(data), "application/octet-stream")}, ) return r def _default_column_mapping() -> dict: """Mapare de coloane implicita pentru fisierul test.""" return { "VIN": "vin", "Nr inmatriculare": "nr_inmatriculare", "Data prestatie": "data_prestatie", "Odometru final": "odometru_final", "Operatie": "operatie", } def _setup_nomenclator(client: TestClient) -> None: """Seed nomenclator cu un cod de prestatie pentru teste.""" # Folosim POST /v1/prezentari pentru a forta seed-ul nomenclatorului # care are loc in init_db -> seed_nomenclator_if_empty pass # seed-ul se face automat in init_db def _seed_operation_mapping(client: TestClient, cod_op: str = "Revizie", cod_prest: str = "OE-1") -> None: """Salveaza o mapare de operatii pentru teste.""" # Adauga mai intai in nomenclator daca nu exista (prin POST prezentare care creeaza cod) # De fapt, cod OE-1 e in nomenclatorul seed client.post("/v1/mapari", json={ "cod_op_service": cod_op, "cod_prestatie": cod_prest, "auto_send": True, }) # =========================================================================== # # #11 — Upload + staging (U1+T4) # # =========================================================================== # class TestUploadStaging: def test_upload_xlsx_ok(self, client): """Upload xlsx valid -> import_id + columns + sample_rows.""" data = _make_xlsx([_HEADER, _ROW_OK, _ROW_OK2]) r = _upload_file(client, data, "test.xlsx") assert r.status_code == 200, r.text body = r.json() assert "import_id" in body assert body["columns"] == _HEADER assert body["total_rows"] == 2 assert len(body["sample_rows"]) == 2 def test_upload_csv_semicolon(self, client): """Upload CSV cu ';' (export RO) -> parsare corecta.""" data = _make_csv([_HEADER, _ROW_OK], delimiter=";") r = _upload_file(client, data, "test.csv") assert r.status_code == 200, r.text body = r.json() assert body["columns"] == _HEADER assert body["total_rows"] == 1 def test_upload_fisier_prea_mare(self, client): """Fisier >5MB -> 413.""" data = b"PK" + b"X" * (5 * 1024 * 1024 + 100) r = _upload_file(client, data, "mare.xlsx") assert r.status_code in (413, 422) def test_upload_format_invalid(self, client): """Fisier tip nesuportat -> 422.""" r = _upload_file(client, b"data random", "test.dbf") assert r.status_code == 422 def test_issue5a_raw_json_criptat(self, client): """Issue 5a: raw_json din import_rows trebuie sa fie criptat (ciphertext la rest).""" data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") assert r.status_code == 200 import_id = r.json()["import_id"] # Citeste direct din DB si verifica ca raw_json e criptat (nu JSON plain) import sqlite3 from app.config import get_settings conn = sqlite3.connect(get_settings().db_path) conn.row_factory = sqlite3.Row try: row = conn.execute( "SELECT raw_json FROM import_rows WHERE batch_id=? ORDER BY row_index LIMIT 1", (import_id,), ).fetchone() assert row is not None raw = row["raw_json"] # Ciphertext Fernet incepe cu "gAAA" (base64url) assert not raw.startswith("{"), "raw_json trebuie sa fie criptat, nu JSON plain" # Verifica ca se poate decripta from app.crypto import decrypt_creds decrypted = decrypt_creds(raw) assert decrypted is not None assert "VIN" in decrypted or any("VIN" in k for k in decrypted.keys()) finally: conn.close() def test_issue5b_fuzzy_coloane_refoloseste_normalize_for_match(self, client): """Issue 5b: fuzzy_suggestions din raspuns foloseste normalize_for_match (fara duplicat).""" data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") assert r.status_code == 200 body = r.json() # Daca nu exista mapare, trebuie sa avem fuzzy_suggestions if "fuzzy_suggestions" in body: sugg = body["fuzzy_suggestions"] # "VIN" trebuie sa aiba sugestia "vin" cu scor mare if "VIN" in sugg: camps = [s["camp_canonic"] for s in sugg["VIN"]] assert "vin" in camps, f"'vin' trebuie sa fie in sugestii pentru 'VIN', primit: {camps}" if "Odometru final" in sugg: camps = [s["camp_canonic"] for s in sugg["Odometru final"]] assert "odometru_final" in camps, f"'odometru_final' trebuie in sugestii" def test_drift_semnatura_coloane(self, client): """T4/D3: upload 2 cu coloane mutate -> mapping_status='new' (nu aplica orb).""" # Upload 1 cu header standard data1 = _make_xlsx([_HEADER, _ROW_OK]) r1 = _upload_file(client, data1, "test.xlsx") assert r1.status_code == 200 import_id1 = r1.json()["import_id"] # Salveaza maparea pentru upload 1 client.post( f"/v1/import/{import_id1}/column-mapping", json={"json_mapare": _default_column_mapping()}, ) # Upload 2 cu header DIFERIT (coloane mutate/redenumite) header2 = ["Sasiu", "Inmatriculare", "Data", "KM", "Lucrare"] data2 = _make_xlsx([header2, ["WVWZZZ1KZAW000125", "B1XYZ", "2026-06-10", "50000", "ITP"]]) r2 = _upload_file(client, data2, "test2.xlsx") assert r2.status_code == 200 body2 = r2.json() # Semnatura diferita -> nu se aplica maparea veche assert body2["mapping_status"] == "new", \ f"Drift coloane trebuie detectat, primit mapping_status={body2['mapping_status']}" def test_aceeasi_semnatura_returneaza_maparea(self, client): """Dupa salvarea maparii, al doilea upload cu aceleasi coloane o returneaza direct.""" data = _make_xlsx([_HEADER, _ROW_OK]) r1 = _upload_file(client, data, "test.xlsx") import_id = r1.json()["import_id"] # Salveaza maparea rc = client.post( f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _default_column_mapping()}, ) assert rc.status_code == 200 # Upload 2 cu aceleasi coloane r2 = _upload_file(client, data, "test.xlsx") assert r2.status_code == 200 body2 = r2.json() assert body2["mapping_status"] == "matched" assert "column_mapping" in body2 def test_upload_xlsx_multisheet_returneaza_eroare_cu_sheets(self, client): """Xlsx cu 2 sheet-uri non-goale -> 422 cu lista de sheet-uri.""" wb = openpyxl.Workbook() ws1 = wb.active ws1.title = "Iunie" for row in [_HEADER, _ROW_OK]: ws1.append(row) ws2 = wb.create_sheet("Iulie") for row in [_HEADER, _ROW_OK2]: ws2.append(row) buf = io.BytesIO() wb.save(buf) r = _upload_file(client, buf.getvalue(), "multi.xlsx") assert r.status_code == 422 body = r.json() assert body["detail"]["error"] == "multiple_sheets" assert "Iunie" in body["detail"]["sheets"] def test_upload_xlsx_multisheet_cu_sheet_ales(self, client): """Dupa alegere sheet -> parsare corecta.""" wb = openpyxl.Workbook() ws1 = wb.active ws1.title = "Iunie" for row in [_HEADER, _ROW_OK]: ws1.append(row) ws2 = wb.create_sheet("Iulie") for row in [_HEADER, _ROW_OK2]: ws2.append(row) buf = io.BytesIO() wb.save(buf) r = client.post( "/v1/import?sheet_name=Iulie", files={"file": ("multi.xlsx", io.BytesIO(buf.getvalue()), "application/octet-stream")}, ) assert r.status_code == 200 body = r.json() assert body["total_rows"] == 1 assert body["sample_rows"][0]["VIN"] == _ROW_OK2[0] def test_purge_after_setat_la_insert(self, client): """T16: purge_after trebuie setat la insert import_batches.""" data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") import_id = r.json()["import_id"] import sqlite3 from app.config import get_settings conn = sqlite3.connect(get_settings().db_path) conn.row_factory = sqlite3.Row try: row = conn.execute( "SELECT purge_after FROM import_batches WHERE id=?", (import_id,) ).fetchone() assert row["purge_after"] is not None, "purge_after trebuie setat la insert" finally: conn.close() # =========================================================================== # # #11 — Mapare coloane (T4) # # =========================================================================== # class TestColumnMapping: def test_save_column_mapping(self, client): """Salveaza maparea de coloane pentru un batch.""" data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") import_id = r.json()["import_id"] rc = client.post( f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _default_column_mapping()}, ) assert rc.status_code == 200 assert "signature" in rc.json() def test_get_column_mapping_dupa_salvare(self, client): """GET column-mapping returneaza maparea salvata.""" data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") import_id = r.json()["import_id"] client.post( f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _default_column_mapping()}, ) rg = client.get(f"/v1/import/{import_id}/column-mapping") assert rg.status_code == 200 body = rg.json() assert body["status"] == "matched" assert body["column_mapping"] == _default_column_mapping() def test_get_column_mapping_fara_salvare_returneaza_sugestii(self, client): """GET column-mapping fara mapare salvata -> sugestii fuzzy.""" data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") import_id = r.json()["import_id"] rg = client.get(f"/v1/import/{import_id}/column-mapping") assert rg.status_code == 200 body = rg.json() assert body["status"] == "new" # Trebuie sa aiba sugestii pentru coloane evidente if "fuzzy_suggestions" in body: assert "VIN" in body["fuzzy_suggestions"] def test_column_mapping_batch_inexistent(self, client): """GET/POST pe batch inexistent -> 404.""" r = client.get("/v1/import/99999/column-mapping") assert r.status_code == 404 # =========================================================================== # # #12 — Preview 6 stari (T2 + T11) # # =========================================================================== # class TestPreview: def _upload_and_map(self, client, rows=None): """Fixture: upload + salveaza mapare + seeda nomenclator.""" if rows is None: rows = [_HEADER, _ROW_OK] data = _make_xlsx(rows) r = _upload_file(client, data, "test.xlsx") assert r.status_code == 200, r.text import_id = r.json()["import_id"] # Salveaza maparea de coloane rc = client.post( f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _default_column_mapping()}, ) assert rc.status_code == 200 # Seeda maparea de operatii _seed_operation_mapping(client, "Revizie", "OE-1") return import_id def test_preview_rand_ok(self, client): """Rand valid cu operatie mapata -> stare 'ok'.""" import_id = self._upload_and_map(client) rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200, rp.text body = rp.json() rows = body["rows"] assert len(rows) == 1 # VIN valid, data valida, odometru valid, operatie mapata -> ok assert rows[0]["resolved_status"] == "ok" def test_preview_needs_mapping(self, client): """Rand cu operatie nemapata -> needs_mapping.""" import_id = self._upload_and_map(client, rows=[_HEADER, _ROW_OK2]) # _ROW_OK2 are operatia "Reparatie" care nu e mapata rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200 body = rp.json() assert any(r["resolved_status"] in ("needs_mapping",) for r in body["rows"]) def test_preview_needs_data(self, client): """Rand cu VIN invalid -> needs_data.""" row_bad = ["INVALID_VIN_XX", "B999TST", "2026-06-15", "123456", "Revizie"] import_id = self._upload_and_map(client, rows=[_HEADER, row_bad]) _seed_operation_mapping(client, "Revizie", "OE-1") rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200 body = rp.json() assert any(r["resolved_status"] in ("needs_data",) for r in body["rows"]) def test_preview_already_sent_dupa_submit(self, client): """Rand deja trimis prin API -> stare already_sent la preview (T2/D5).""" # Trimite prin API canalul standard client.post("/v1/prezentari", json={ "rar_credentials": {"email": "x@y.ro", "password": "s"}, "prezentari": [{ "vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST", "data_prestatie": "2026-06-15", "odometru_final": "123456", "prestatii": [{"cod_prestatie": "OE-1"}], }], }) # Acum upload acelasi rand import_id = self._upload_and_map(client, rows=[_HEADER, _ROW_OK]) _seed_operation_mapping(client, "Revizie", "OE-1") rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200 body = rp.json() # Randul trebuie sa fie already_sent (cheia idempotency exista deja) statuses = [r["resolved_status"] for r in body["rows"]] assert "already_sent" in statuses, f"Asteptat 'already_sent', primit: {statuses}" def test_preview_duplicate_in_file(self, client): """T11/OV-3: 2 randuri identice in ACELASI fisier -> duplicate_in_file.""" # Acelasi rand de doua ori rows = [_HEADER, _ROW_OK, _ROW_OK] # duplicat exact import_id = self._upload_and_map(client, rows=rows) _seed_operation_mapping(client, "Revizie", "OE-1") rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200 body = rp.json() statuses = [r["resolved_status"] for r in body["rows"]] assert "duplicate_in_file" in statuses, \ f"Asteptat 'duplicate_in_file', primit: {statuses}" def test_preview_already_sent_batch_lookup_nu_n_plus_1(self, client): """Eng#5: already_sent lookup BATCH (nu N+1) — ≤7 interogari pentru 5 randuri.""" # Cream 5 randuri distincte rows_data = [_HEADER] for i in range(5): rows_data.append([ f"WVWZZZ1KZAW00{i:04d}", f"B00{i}TST", "2026-06-15", str(100000 + i), "Revizie", ]) import_id = self._upload_and_map(client, rows=rows_data) _seed_operation_mapping(client, "Revizie", "OE-1") # Aceasta verificare e comportamentala: preview trebuie sa functioneze # corect (nu testam direct nr. de SQL queries, ci ca raspunsul e corect) rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200 body = rp.json() assert len(body["rows"]) == 5 def test_preview_fara_mapare_coloane_returneaza_422(self, client): """Preview fara mapare de coloane configurata -> 422.""" data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") import_id = r.json()["import_id"] # Nu salvam maparea de coloane rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 422 assert "no_column_mapping" in rp.json()["detail"]["error"] def test_preview_summary_ok(self, client): """Preview intoarce si summary cu contoare per stare.""" import_id = self._upload_and_map(client, rows=[_HEADER, _ROW_OK, _ROW_OK2]) _seed_operation_mapping(client, "Revizie", "OE-1") rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200 body = rp.json() assert "summary" in body # Suma totala = nr randuri total = sum(body["summary"].values()) assert total == len(body["rows"]) # =========================================================================== # # #13 — Commit gate HARD + atestare + TOCTOU (T5 + T12) # # =========================================================================== # class TestCommit: def _upload_preview_ok(self, client): """Upload + mapeaza + preview -> returneaza import_id cu randuri ok.""" data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") import_id = r.json()["import_id"] client.post( f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _default_column_mapping()}, ) _seed_operation_mapping(client, "Revizie", "OE-1") # Preview pentru a calcula starile rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200 return import_id, rp.json() def test_commit_cu_n_corect_enqueued(self, client): """Commit cu N corect -> rand in submissions cu status queued.""" import_id, preview = self._upload_preview_ok(client) n_ok = preview["summary"].get("ok", 0) assert n_ok > 0, "Trebuie cel putin un rand ok" rc = client.post(f"/v1/import/{import_id}/commit", json={ "n_confirmat": n_ok, "reviewed_rows": [], }) assert rc.status_code == 200, rc.text body = rc.json() assert body["enqueued"] == n_ok def test_commit_cu_n_gresit_reject(self, client): """T5/D3: commit cu N gresit -> 422, nu enqueue.""" import_id, preview = self._upload_preview_ok(client) n_ok = preview["summary"].get("ok", 0) # Trimitem n_confirmat + 1 (gresit) rc = client.post(f"/v1/import/{import_id}/commit", json={ "n_confirmat": n_ok + 1, "reviewed_rows": [], }) assert rc.status_code == 422 detail = rc.json()["detail"] assert "confirmare_gresita" in detail.get("error", ""), \ f"Eroare neasteptata: {detail}" def test_commit_log_atestare(self, client): """T12/Voce#9: commit scrie import_attestations cu rows_hash + n_confirmed.""" import_id, preview = self._upload_preview_ok(client) n_ok = preview["summary"].get("ok", 0) rc = client.post(f"/v1/import/{import_id}/commit", json={ "n_confirmat": n_ok, "reviewed_rows": [], "confirmed_by": "test@example.com", }) assert rc.status_code == 200 body = rc.json() assert body["enqueued"] == n_ok assert body["rows_hash"] # sha256 non-gol # Verifica direct in DB ca exista atestarea import sqlite3 from app.config import get_settings conn = sqlite3.connect(get_settings().db_path) conn.row_factory = sqlite3.Row try: att = conn.execute( "SELECT * FROM import_attestations WHERE batch_id=?", (import_id,) ).fetchone() assert att is not None, "import_attestations trebuie sa contina o inregistrare" assert att["n_confirmed"] == n_ok assert att["rows_hash"] == body["rows_hash"] assert att["confirmed_by"] == "test@example.com" finally: conn.close() def test_commit_batch_id_setat_pe_submission(self, client): """T7: submission creata la commit trebuie sa aiba batch_id + row_index setate.""" import_id, preview = self._upload_preview_ok(client) n_ok = preview["summary"].get("ok", 0) rc = client.post(f"/v1/import/{import_id}/commit", json={ "n_confirmat": n_ok, "reviewed_rows": [], }) assert rc.status_code == 200 submissions = rc.json()["submissions"] assert len(submissions) > 0 import sqlite3 from app.config import get_settings conn = sqlite3.connect(get_settings().db_path) conn.row_factory = sqlite3.Row try: for sub in submissions: row = conn.execute( "SELECT batch_id, row_index FROM submissions WHERE id=?", (sub["submission_id"],), ).fetchone() assert row["batch_id"] == import_id, "batch_id trebuie setat" assert row["row_index"] is not None, "row_index trebuie setat" finally: conn.close() def test_commit_toctou_cheie_inserata_concurent(self, client): """Issue 1 (TOCTOU): cheie inserata de canal concurent -> reclasificata already_sent. Simulam TOCTOU inserand cheia direct in submissions inainte de commit. """ import_id, preview = self._upload_preview_ok(client) n_ok = preview["summary"].get("ok", 0) # Gaseste cheia de idempotenta a randului ok ok_rows = [r for r in preview["rows"] if r["resolved_status"] == "ok"] assert len(ok_rows) > 0 idem_key = ok_rows[0]["idempotency_key"] assert idem_key, "idempotency_key trebuie calculat la preview" # Simuleaza canalul concurent: insereaza cheia in submissions import sqlite3 from app.config import get_settings conn = sqlite3.connect(get_settings().db_path) try: conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " "VALUES (?, 1, 'queued', '{}')", (idem_key,), ) conn.commit() finally: conn.close() # Commit-ul trebuie sa detecteze coliziunea si s-o raporteze ca TOCTOU rc = client.post(f"/v1/import/{import_id}/commit", json={ "n_confirmat": n_ok, "reviewed_rows": [], }) # Poate fi 200 cu toctou_collisions sau 422 cu informatii clare # Conform planului Issue 1: reclasificat already_sent, nu rollback # Dar n_enqueued va fi 0 daca toate colideaza if rc.status_code == 200: body = rc.json() assert body["toctou_collisions"] == [ok_rows[0]["row_index"]] or body["enqueued"] == 0 # Sau 422 daca gate-ul HARD detecteaza ca n_ok actual != n_confirmat # (dupa reclasificare, n_total_ok scade) def test_commit_needs_review_nebifat_exclus_din_n(self, client): """Voce#1: rand needs_review nebifat explicit -> NU intra in N, NU se enqueued.""" # Rand cu VIN numeric (coercion -> needs_review) import datetime as dt wb = openpyxl.Workbook() ws = wb.active ws.append(_HEADER) # VIN ca int (numeric) -> coercion flag -> needs_review ws.cell(row=2, column=1).value = 123456789012345 # VIN numeric ws.cell(row=2, column=2).value = "B999TST" ws.cell(row=2, column=3).value = "2026-06-15" ws.cell(row=2, column=4).value = 123456 ws.cell(row=2, column=5).value = "Revizie" buf = io.BytesIO() wb.save(buf) r = _upload_file(client, buf.getvalue(), "test.xlsx") assert r.status_code == 200 import_id = r.json()["import_id"] client.post(f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _default_column_mapping()}) _seed_operation_mapping(client, "Revizie", "OE-1") rp = client.get(f"/v1/import/{import_id}/preview") assert rp.status_code == 200 body_preview = rp.json() review_rows = [r for r in body_preview["rows"] if r["resolved_status"] == "needs_review"] ok_rows_count = body_preview["summary"].get("ok", 0) review_count = len(review_rows) if review_count == 0: pytest.skip("Niciun rand needs_review in acest test — skip") # Confirma FARA a bifa needs_review -> n_confirmat = ok_rows_count (fara review) # Dar n_ok e 0 daca tot fisierul e needs_review rc = client.post(f"/v1/import/{import_id}/commit", json={ "n_confirmat": ok_rows_count, "reviewed_rows": [], # nu bifam needs_review }) if ok_rows_count == 0: # 0 randuri ok + 0 reviewed = eroare assert rc.status_code in (422,) else: # Randurile needs_review NU sunt enqueued (nu le-am bifat) assert rc.status_code == 200 body = rc.json() assert body["enqueued"] == ok_rows_count def test_commit_double_call_returneaza_409(self, client): """Commit de doua ori pe acelasi batch -> 409 (deja comis).""" import_id, preview = self._upload_preview_ok(client) n_ok = preview["summary"].get("ok", 0) client.post(f"/v1/import/{import_id}/commit", json={"n_confirmat": n_ok, "reviewed_rows": []}) r2 = client.post(f"/v1/import/{import_id}/commit", json={"n_confirmat": n_ok, "reviewed_rows": []}) assert r2.status_code == 409 def test_atestare_purge_after_setat_pe_submission(self, client): """T16: submissions create la commit trebuie sa aiba purge_after setat.""" import_id, preview = self._upload_preview_ok(client) n_ok = preview["summary"].get("ok", 0) rc = client.post(f"/v1/import/{import_id}/commit", json={ "n_confirmat": n_ok, "reviewed_rows": [] }) assert rc.status_code == 200 submissions = rc.json()["submissions"] if not submissions: pytest.skip("Nicio submission creata") import sqlite3 from app.config import get_settings conn = sqlite3.connect(get_settings().db_path) conn.row_factory = sqlite3.Row try: for sub in submissions: row = conn.execute( "SELECT purge_after FROM submissions WHERE id=?", (sub["submission_id"],) ).fetchone() assert row["purge_after"] is not None, "purge_after trebuie setat pe submission" finally: conn.close() # =========================================================================== # # #14 — Export randuri esuate CSV (T8) # # =========================================================================== # class TestExportFailed: def _setup_batch_with_bad_rows(self, client): """Upload cu randuri esuate (VIN invalid).""" row_bad = ["INVALID_VIN_XXXXXXX", "B999TST", "2026-06-15", "123456", "Revizie"] data = _make_xlsx([_HEADER, row_bad]) r = _upload_file(client, data, "test.xlsx") import_id = r.json()["import_id"] client.post(f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _default_column_mapping()}) _seed_operation_mapping(client, "Revizie", "OE-1") # Preview pentru a calcula starile client.get(f"/v1/import/{import_id}/preview") return import_id def test_export_failed_returneaza_csv(self, client): """Export randuri esuate -> CSV cu header + randuri.""" import_id = self._setup_batch_with_bad_rows(client) r = client.get(f"/v1/import/{import_id}/export-failed") assert r.status_code == 200 assert "text/csv" in r.headers["content-type"] # Parseaza CSV content = r.text reader = csv.DictReader(io.StringIO(content)) rows = list(reader) assert len(rows) > 0, "CSV trebuie sa contina cel putin un rand esuat" def test_export_failed_contine_motiv_eroare(self, client): """CSV de export contine coloana 'error' cu motivul.""" import_id = self._setup_batch_with_bad_rows(client) r = client.get(f"/v1/import/{import_id}/export-failed") assert r.status_code == 200 reader = csv.DictReader(io.StringIO(r.text)) rows = list(reader) assert len(rows) > 0 # Fiecare rand trebuie sa aiba coloana error for row in rows: assert "error" in row, "Coloana 'error' trebuie sa fie prezenta" assert row["resolved_status"] in ("needs_data", "needs_mapping", "needs_review") def test_export_failed_batch_inexistent(self, client): """Export pe batch inexistent -> 404.""" r = client.get("/v1/import/99999/export-failed") assert r.status_code == 404 def test_export_failed_fara_randuri_esuate(self, client): """Export pe batch fara randuri esuate -> CSV gol (doar header).""" # Upload cu rand ok data = _make_xlsx([_HEADER, _ROW_OK]) r = _upload_file(client, data, "test.xlsx") import_id = r.json()["import_id"] client.post(f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _default_column_mapping()}) _seed_operation_mapping(client, "Revizie", "OE-1") client.get(f"/v1/import/{import_id}/preview") r = client.get(f"/v1/import/{import_id}/export-failed") assert r.status_code == 200 reader = csv.DictReader(io.StringIO(r.text)) rows = list(reader) assert len(rows) == 0, "Niciun rand esuat in batch cu randuri ok" # =========================================================================== # # Regresie: reconcile.py ramane op-blind (OV-3) # # =========================================================================== # class TestReconcileRegresie: def test_match_finalizata_ramane_op_blind(self): """OV-3: reconcile.py trebuie sa ramana op-blind (nu editat de import). Importam reconcile si verificam ca nu s-au adaugat parametri de operatie. """ from app.reconcile import match_finalizata import inspect sig = inspect.signature(match_finalizata) params = set(sig.parameters.keys()) # Trebuie sa aiba exact acesti parametri (op-blind by design) expected = {"finalizate", "vin", "data_prestatie", "odometru_final"} assert not (params - expected - {"self"}), \ f"match_finalizata are parametri neasteptati: {params - expected}" # Nu trebuie sa aiba parametri de operatie assert "cod_prestatie" not in params assert "operatie" not in params assert "cod_op_service" not in params