Files
rar-autopass/tests/test_import_api.py
Claude Agent 44f261e269 refactor: comentarii strict functionale, fara referinte PRD/stories
Curatare globala a comentariilor si docstring-urilor (app, tools, teste,
scripturi): eliminate referintele la PRD-uri, US-xxx, task-uri istorice si
review-uri; pastrata doar informatia functionala, formulata scurt. Regula
adaugata in CLAUDE.md (sectiunea Stil). Fara modificari de cod sau comportament.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:48:38 +00:00

826 lines
33 KiB
Python

"""Teste API import — POST /v1/import, preview, commit, export-failed.
Acopera:
- upload + staging + mapare coloane semnatura/drift/fuzzy
- preview 6 stari + already_sent batch lookup + intra-batch collision
- gate HARD confirmare + atestare valori + commit ON CONFLICT (TOCTOU)
- 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."""
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."""
client.post("/v1/mapari", json={
"cod_op_service": cod_op,
"cod_prestatie": cod_prest,
"auto_send": True,
})
# =========================================================================== #
# Upload + staging #
# =========================================================================== #
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):
"""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):
"""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):
"""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):
"""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()
# =========================================================================== #
# Mapare coloane #
# =========================================================================== #
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
# =========================================================================== #
# Preview 6 stari #
# =========================================================================== #
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."""
# 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):
"""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):
"""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"])
# =========================================================================== #
# Commit gate HARD + atestare + TOCTOU #
# =========================================================================== #
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):
"""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):
"""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):
"""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):
"""Cheie inserata de canal concurent (TOCTOU) -> 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
# 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):
"""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):
"""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()
# =========================================================================== #
# Export randuri esuate CSV #
# =========================================================================== #
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 #
# =========================================================================== #
class TestReconcileRegresie:
def test_match_finalizata_ramane_op_blind(self):
"""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