feat(import): #11-14 router import Treapta 2 — upload+staging+mapare+preview+commit+export

Implementeaza app/api/v1/import_router.py (router nou, montat in app):

POST /v1/import — upload xlsx/csv, staging in import_batches/import_rows,
  PII criptat Fernet (Issue 5a), BEGIN IMMEDIATE+executemany (Issue 6),
  purge_after 90z (T16), sugestii fuzzy coloane DRY (Issue 5b/Eng#4),
  detectie drift semnatura (T4/D3), multisheet support

GET/POST /v1/import/{id}/column-mapping — mapare coloane per cont cu
  semnatura + drift detection

GET /v1/import/{id}/preview — 6 stari per rand (ok/needs_mapping/needs_data/
  needs_review/already_sent/duplicate_in_file), already_sent batch lookup
  nu N+1 (Eng#5), intra-batch collision EXCLUSIV preview (OV-3/T11)

POST /v1/import/{id}/commit — gate HARD N confirmat (T5/D3), atestare pe
  valori (Voce#1), INSERT ON CONFLICT DO NOTHING TOCTOU (Issue 1/T12),
  import_attestations rows_hash+n_confirmed (Voce#9), batch_id/row_index (T7)

GET /v1/import/{id}/export-failed — CSV randuri esuate cu motiv (T8)

Teste: 36 cazuri noi in tests/test_import_api.py; 243 total, toate verzi.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-16 20:41:59 +00:00
parent 2c8367109c
commit 70f717d874
3 changed files with 1957 additions and 0 deletions

1125
app/api/v1/import_router.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import __version__ from . import __version__
from .api.v1.import_router import router as import_v1_router
from .api.v1.router import router as api_v1_router from .api.v1.router import router as api_v1_router
from .config import get_settings from .config import get_settings
from .db import get_connection, init_db, queue_depth, read_heartbeat from .db import get_connection, init_db, queue_depth, read_heartbeat
@@ -56,6 +57,7 @@ _STATIC_DIR = Path(__file__).resolve().parent / "web" / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
app.include_router(api_v1_router) app.include_router(api_v1_router)
app.include_router(import_v1_router)
app.include_router(web_router) app.include_router(web_router)

830
tests/test_import_api.py Normal file
View File

@@ -0,0 +1,830 @@
"""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