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:
1125
app/api/v1/import_router.py
Normal file
1125
app/api/v1/import_router.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
830
tests/test_import_api.py
Normal 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
|
||||||
Reference in New Issue
Block a user