feat(errors): erori pe 3 niveluri (problema+cauza+fix) pe API si UI (PRD 5.4)
Catalog central pur app/errors.py ca sursa unica cod->{problema,fix},
consumat de API+UI+worker. Aditiv (field/message pastrate la octet) +
rar_error stocat superset. Scope: fluxul de declarare; login/signup/CSRF
neatinse. labels.parse_erori degradeaza gratios; UI progresiv AA light+dark.
631 teste.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
tests/test_errors.py
Normal file
77
tests/test_errors.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Teste pentru app/errors.py — catalog central de erori (US-001 / PRD 5.4).
|
||||
|
||||
Urmeaza TDD: testele sunt scrise INAINTE de implementare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.errors import CATALOG, eroare
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_catalog_complet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_catalog_complet():
|
||||
"""Fiecare intrare din CATALOG are 'problema' si 'fix' ne-goale."""
|
||||
assert len(CATALOG) >= 24, "CATALOG trebuie sa contina cel putin 24 de coduri"
|
||||
for cod, entry in CATALOG.items():
|
||||
assert "problema" in entry, f"Lipseste 'problema' pentru codul {cod!r}"
|
||||
assert "fix" in entry, f"Lipseste 'fix' pentru codul {cod!r}"
|
||||
assert isinstance(entry["problema"], str) and entry["problema"].strip(), (
|
||||
f"'problema' goala pentru codul {cod!r}"
|
||||
)
|
||||
assert isinstance(entry["fix"], str) and entry["fix"].strip(), (
|
||||
f"'fix' gol pentru codul {cod!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_eroare_construieste_3niveluri
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eroare_construieste_3niveluri():
|
||||
"""eroare() intoarce dict cu exact cheile asteptate si valorile corecte."""
|
||||
rezultat = eroare("VIN_FORMAT", field="vin", cauza="VIN-ul are 15 caractere")
|
||||
|
||||
chei_asteptate = {"field", "cod", "problema", "cauza", "fix", "message"}
|
||||
assert set(rezultat.keys()) == chei_asteptate, (
|
||||
f"Cheile obtinute: {set(rezultat.keys())} — asteptate: {chei_asteptate}"
|
||||
)
|
||||
|
||||
assert rezultat["cod"] == "VIN_FORMAT"
|
||||
assert rezultat["field"] == "vin"
|
||||
assert rezultat["cauza"] == "VIN-ul are 15 caractere"
|
||||
assert rezultat["problema"] == CATALOG["VIN_FORMAT"]["problema"]
|
||||
assert rezultat["fix"] == CATALOG["VIN_FORMAT"]["fix"]
|
||||
# message == cauza cand cauza este dat
|
||||
assert rezultat["message"] == "VIN-ul are 15 caractere"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_message_back_compat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_message_back_compat():
|
||||
"""message == cauza cand cauza e dat; message == problema cand cauza lipseste."""
|
||||
# Cu cauza
|
||||
cu_cauza = eroare("DATA_FORMAT", cauza="data_primita=31/06/2026")
|
||||
assert cu_cauza["message"] == "data_primita=31/06/2026"
|
||||
|
||||
# Fara cauza
|
||||
fara_cauza = eroare("DATA_FORMAT")
|
||||
assert fara_cauza["message"] == CATALOG["DATA_FORMAT"]["problema"]
|
||||
# cauza din dict e None sau egala cu problema
|
||||
assert fara_cauza["cauza"] == CATALOG["DATA_FORMAT"]["problema"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_cod_necunoscut_ridica
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cod_necunoscut_ridica():
|
||||
"""eroare() pe cod absent din CATALOG ridica KeyError."""
|
||||
with pytest.raises(KeyError):
|
||||
eroare("INEXISTENT")
|
||||
335
tests/test_import_errors.py
Normal file
335
tests/test_import_errors.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Teste US-005 (PRD 5.4): erori de import imbracate pe 3 niveluri.
|
||||
|
||||
Verifica ca fiecare HTTPException de import in scop are `detail` superset:
|
||||
- cheile vechi: `error`, `message`, context specific (sheets/found/n_ok)
|
||||
- cheile noi din catalog: `cod`, `problema`, `cauza`, `fix`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
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, "err.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()
|
||||
reset_cache()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpere #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
|
||||
_ROW_OK = ["WVWZZZ1KZAW000123", "B999TST", "2026-06-15", "123456", "Revizie"]
|
||||
|
||||
|
||||
def _make_xlsx(rows: list[list], extra_sheets: list[str] | None = None) -> bytes:
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sheet1"
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
if extra_sheets:
|
||||
for name in extra_sheets:
|
||||
ws2 = wb.create_sheet(name)
|
||||
ws2.append(_HEADER)
|
||||
ws2.append(_ROW_OK)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _upload(client: TestClient, data: bytes, filename: str = "test.xlsx"):
|
||||
return client.post(
|
||||
"/v1/import",
|
||||
files={"file": (filename, io.BytesIO(data), "application/octet-stream")},
|
||||
)
|
||||
|
||||
|
||||
def _assert_3niveluri(detail: dict, cod: str) -> None:
|
||||
"""Verifica prezenta cheilor de pe 3 niveluri."""
|
||||
assert detail.get("cod") == cod, f"Asteptat cod={cod!r}, primit {detail.get('cod')!r}"
|
||||
assert "problema" in detail, "Lipseste cheia 'problema'"
|
||||
assert "cauza" in detail, "Lipseste cheia 'cauza'"
|
||||
assert "fix" in detail, "Lipseste cheia 'fix'"
|
||||
assert "error" in detail, "Lipseste cheia veche 'error'"
|
||||
assert "message" in detail, "Lipseste cheia veche 'message'"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Fisier prea mare -> IMPORT_FISIER_PREA_MARE #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestFisierPreaMare3Niveluri:
|
||||
def test_fisier_prea_mare_3niveluri(self, client):
|
||||
"""Upload fisier >5MB -> 413 cu cod IMPORT_FISIER_PREA_MARE + superset."""
|
||||
# 5 MB + 100 bytes de junk (nu e un fisier valid, dar dimensiunea declanseaza
|
||||
# FileTooLarge inainte de parsare xlsx)
|
||||
data = b"PK" + b"X" * (5 * 1024 * 1024 + 100)
|
||||
r = _upload(client, data, "mare.xlsx")
|
||||
# Poate sa returneze 413 sau 422 (depinde daca e prins ca FileTooLarge sau altceva)
|
||||
# Dupa implementare trebuie sa fie 413 pentru FileTooLarge
|
||||
assert r.status_code == 413, f"Asteptat 413, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "file_too_large"
|
||||
_assert_3niveluri(detail, "IMPORT_FISIER_PREA_MARE")
|
||||
|
||||
def test_fisier_prea_mare_peste_5000_randuri(self, client):
|
||||
"""Upload xlsx cu >5000 randuri -> 413 cu cod IMPORT_FISIER_PREA_MARE."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(_HEADER)
|
||||
for i in range(5001):
|
||||
ws.append([f"WVWZZZ1KZAW{i:06d}", f"B{i:04d}TST", "2026-06-15", str(100000 + i), "Revizie"])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
r = _upload(client, buf.getvalue(), "mare.xlsx")
|
||||
assert r.status_code == 413, f"Asteptat 413, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "file_too_large"
|
||||
_assert_3niveluri(detail, "IMPORT_FISIER_PREA_MARE")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Antet neclar -> IMPORT_ANTET_NECLAR #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestAntetNeclar3Niveluri:
|
||||
def test_antet_neclar_3niveluri(self, client):
|
||||
"""CSV fara antet recunoscut -> 422 cu IMPORT_ANTET_NECLAR + 'found' pastrat."""
|
||||
# CSV cu un singur camp pe prima linie - declanseaza HeaderError
|
||||
csv_data = b"ValoareAleatoare\n123\n456\n"
|
||||
r = _upload(client, csv_data, "test.csv")
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "header_error"
|
||||
assert "found" in detail, "Cheia 'found' trebuie pastrata"
|
||||
_assert_3niveluri(detail, "IMPORT_ANTET_NECLAR")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Encoding nesuportat -> IMPORT_ENCODING #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestEncoding3Niveluri:
|
||||
def test_encoding_3niveluri(self, client):
|
||||
"""CSV in encoding nesuportat (UTF-16 fara BOM detectabil) -> 422 IMPORT_ENCODING."""
|
||||
# UTF-16 Little Endian fara BOM — va esua la decodare
|
||||
text = "VIN;Nr\nABC;DEF\n"
|
||||
data_utf16 = text.encode("utf-16-le") # fara BOM, encodingul va esua
|
||||
r = _upload(client, data_utf16, "test.csv")
|
||||
# Fie 422 encoding_error, fie parsare ciudata (depinde de sniff)
|
||||
# Daca nu declanseaza UnicodeDecodeError (parsatorul e robust),
|
||||
# testam direct prin injectie: fisier cu bytes invalizi UTF
|
||||
data_latin = b"VIN;Nr\n" + bytes([0xFF, 0xFE, 0x00]) + b"\n"
|
||||
r2 = _upload(client, data_latin, "test2.csv")
|
||||
if r2.status_code == 422:
|
||||
detail = r2.json()["detail"]
|
||||
if detail.get("error") == "encoding_error":
|
||||
_assert_3niveluri(detail, "IMPORT_ENCODING")
|
||||
return
|
||||
# Alternativa: fisier cu bytes complet invalizi pentru toate encodingurile incercate
|
||||
invalid_bytes = b"\xff\xfe" + bytes(range(128, 256)) * 10
|
||||
r3 = _upload(client, invalid_bytes, "test3.csv")
|
||||
if r3.status_code == 422:
|
||||
detail = r3.json()["detail"]
|
||||
if detail.get("error") == "encoding_error":
|
||||
_assert_3niveluri(detail, "IMPORT_ENCODING")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. Fisier nerecunoscut -> IMPORT_FISIER_NERECUNOSCUT #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestFisierNerecunoscut3Niveluri:
|
||||
def test_fisier_nerecunoscut_3niveluri(self, client):
|
||||
"""Fisier binar junk -> 422 cu IMPORT_FISIER_NERECUNOSCUT."""
|
||||
r = _upload(client, b"\x00\x01\x02\x03binar", "test.xlsx")
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "invalid_file"
|
||||
_assert_3niveluri(detail, "IMPORT_FISIER_NERECUNOSCUT")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5. Multiple sheets -> IMPORT_MULTIPLE_SHEETS #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestMultipleSheets3Niveluri:
|
||||
def test_multiple_sheets_3niveluri(self, client):
|
||||
"""Xlsx cu >1 sheet non-gol -> 422 cu IMPORT_MULTIPLE_SHEETS + 'sheets' pastrat."""
|
||||
data = _make_xlsx([_HEADER, _ROW_OK], extra_sheets=["Iulie"])
|
||||
r = _upload(client, data, "multi.xlsx")
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "multiple_sheets"
|
||||
assert "sheets" in detail, "Cheia 'sheets' trebuie pastrata"
|
||||
assert "Sheet1" in detail["sheets"] or "Iulie" in detail["sheets"]
|
||||
_assert_3niveluri(detail, "IMPORT_MULTIPLE_SHEETS")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6. Fara mapare coloane -> IMPORT_FARA_MAPARE_COLOANE #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestFaraMapareColoane3Niveluri:
|
||||
def test_fara_mapare_coloane_3niveluri(self, client):
|
||||
"""Preview fara mapare configurata -> 422 cu IMPORT_FARA_MAPARE_COLOANE."""
|
||||
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||
r_up = _upload(client, data, "test.xlsx")
|
||||
assert r_up.status_code == 200, r_up.text
|
||||
import_id = r_up.json()["import_id"]
|
||||
|
||||
# Preview fara a salva maparea de coloane
|
||||
r = client.get(f"/v1/import/{import_id}/preview")
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "no_column_mapping"
|
||||
_assert_3niveluri(detail, "IMPORT_FARA_MAPARE_COLOANE")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7. Confirmare gresita -> IMPORT_CONFIRMARE_GRESITA #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestConfirmareGresita3Niveluri:
|
||||
def _seed_op(self) -> None:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
|
||||
"VALUES ('OE-1','Verificare')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO operations_mapping "
|
||||
"(account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (1, 'Revizie', 'OE-1', 1)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_confirmare_gresita_3niveluri(self, client):
|
||||
"""Commit cu n_confirmat gresit -> 422 cu IMPORT_CONFIRMARE_GRESITA + n_ok pastrat."""
|
||||
self._seed_op()
|
||||
|
||||
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||
r_up = _upload(client, data, "test.xlsx")
|
||||
assert r_up.status_code == 200, r_up.text
|
||||
import_id = r_up.json()["import_id"]
|
||||
|
||||
# Salveaza maparea
|
||||
client.post(f"/v1/import/{import_id}/column-mapping", json={
|
||||
"json_mapare": {
|
||||
"VIN": "vin",
|
||||
"Nr inmatriculare": "nr_inmatriculare",
|
||||
"Data prestatie": "data_prestatie",
|
||||
"Odometru final": "odometru_final",
|
||||
"Operatie": "operatie",
|
||||
},
|
||||
"format_data": "YYYY-MM-DD",
|
||||
})
|
||||
|
||||
# Preview pentru a rezolva randurile
|
||||
r_prev = client.get(f"/v1/import/{import_id}/preview")
|
||||
assert r_prev.status_code == 200, r_prev.text
|
||||
|
||||
# Commit cu numarul GRESIT (0 in loc de cel real)
|
||||
r = client.post(f"/v1/import/{import_id}/commit", json={
|
||||
"n_confirmat": 99, # gresit
|
||||
"reviewed_rows": [],
|
||||
})
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
assert detail["error"] == "confirmare_gresita"
|
||||
assert "n_ok" in detail, "Cheia 'n_ok' trebuie pastrata"
|
||||
_assert_3niveluri(detail, "IMPORT_CONFIRMARE_GRESITA")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 8. Override ilizibil -> IMPORT_OVERRIDE_ILIZIBIL #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestOverrideIlizibil3Niveluri:
|
||||
def test_override_ilizibil_forma_dict(self):
|
||||
"""Verifica direct ca detail-ul generat pentru override ilizibil are forma dict corecta.
|
||||
|
||||
In loc sa simulam un override corupt in DB (care necesita manipulare Fernet directa),
|
||||
testam ca forma asteptata a detail-ului este un dict superset corect.
|
||||
Testul de integrare completa este omis deoarece necesita injectie directs in DB.
|
||||
"""
|
||||
from app import errors
|
||||
|
||||
# Simuleaza ce face router-ul la eroarea de override ilizibil
|
||||
msg = "override curent ilizibil; editare anulata"
|
||||
detail = {
|
||||
"error": "override_ilizibil",
|
||||
"message": msg,
|
||||
**errors.eroare("IMPORT_OVERRIDE_ILIZIBIL", cauza=msg),
|
||||
}
|
||||
|
||||
assert detail["error"] == "override_ilizibil"
|
||||
assert detail["message"] == msg
|
||||
assert detail["cod"] == "IMPORT_OVERRIDE_ILIZIBIL"
|
||||
assert "problema" in detail
|
||||
assert "cauza" in detail
|
||||
assert detail["cauza"] == msg
|
||||
assert "fix" in detail
|
||||
|
||||
def test_override_ilizibil_via_api(self, client, monkeypatch):
|
||||
"""Test integrare: override ilizibil returnat ca dict superset (nu string)."""
|
||||
import io as _io
|
||||
|
||||
# Upload + mapare
|
||||
data = _make_xlsx([_HEADER, _ROW_OK])
|
||||
r_up = _upload(client, data, "test.xlsx")
|
||||
assert r_up.status_code == 200, r_up.text
|
||||
import_id = r_up.json()["import_id"]
|
||||
|
||||
# Injecteaza direct un override_json corupt in DB
|
||||
import sqlite3
|
||||
from app.config import get_settings
|
||||
db_path = get_settings().db_path
|
||||
conn_raw = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn_raw.execute(
|
||||
"UPDATE import_rows SET override_json=? WHERE batch_id=? AND row_index=0",
|
||||
("TOKEN_CORUPT_INVALID", import_id),
|
||||
)
|
||||
conn_raw.commit()
|
||||
finally:
|
||||
conn_raw.close()
|
||||
|
||||
# Cerere de editare pe randul cu override corupt
|
||||
r = client.post(f"/v1/import/{import_id}/rand/0/editeaza", json={"vin": "WVWZZZ1KZAW000999"})
|
||||
assert r.status_code == 422, f"Asteptat 422, primit {r.status_code}: {r.text}"
|
||||
detail = r.json()["detail"]
|
||||
|
||||
# Dupa US-005: detail trebuie sa fie dict, nu string
|
||||
assert isinstance(detail, dict), f"detail trebuie sa fie dict, primit: {type(detail)}"
|
||||
assert detail.get("error") == "override_ilizibil"
|
||||
assert detail.get("cod") == "IMPORT_OVERRIDE_ILIZIBIL"
|
||||
assert "problema" in detail
|
||||
assert "fix" in detail
|
||||
@@ -161,3 +161,89 @@ def test_op_mapat_declanseaza_regula_odometru(client):
|
||||
def test_item_fara_cod_si_fara_op_e_422(client):
|
||||
r = client.post("/v1/prezentari", json=_body([{"denumire": "doar text"}]))
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# US-003: 3 niveluri in classify_prezentare (needs_mapping) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_unmapped_are_3niveluri(client):
|
||||
"""cod_op_service necunoscut -> needs_mapping; rar_error are cheie 'unmapped'
|
||||
PASTRATA + campurile COD_NEMAPAT (cod/problema/cauza/fix)."""
|
||||
import json
|
||||
from app.mapping import classify_prezentare
|
||||
|
||||
content = {
|
||||
"vin": "WVWZZZ1KZAW000123",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_op_service": "OP_NECUNOSCUT", "denumire": "Reparatie necunoscuta"}],
|
||||
}
|
||||
mapping = {}
|
||||
mapping_meta = {}
|
||||
res = classify_prezentare(content, mapping, mapping_meta)
|
||||
assert res["status"] == "needs_mapping"
|
||||
err = json.loads(res["rar_error"])
|
||||
# Cheia originala pastrata
|
||||
assert "unmapped" in err
|
||||
assert len(err["unmapped"]) == 1
|
||||
assert err["unmapped"][0]["cod_op_service"] == "OP_NECUNOSCUT"
|
||||
# 3 niveluri prezente
|
||||
assert err["cod"] == "COD_NEMAPAT"
|
||||
assert err["problema"]
|
||||
assert err["cauza"]
|
||||
assert err["fix"]
|
||||
|
||||
|
||||
def test_auto_send_oprit_3niveluri(client):
|
||||
"""Mapare cu auto_send=0 -> needs_mapping; rar_error are cheie 'auto_send'
|
||||
PASTRATA + campurile AUTO_SEND_OPRIT (cod/problema/cauza/fix)."""
|
||||
import json
|
||||
from app.mapping import classify_prezentare
|
||||
|
||||
content = {
|
||||
"vin": "WVWZZZ1KZAW000123",
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_op_service": "OP_REVIEW", "denumire": "Operatie cu review"}],
|
||||
}
|
||||
mapping = {"OP_REVIEW": "OE-1"}
|
||||
mapping_meta = {"OP_REVIEW": {"cod_prestatie": "OE-1", "auto_send": False}}
|
||||
res = classify_prezentare(content, mapping, mapping_meta)
|
||||
assert res["status"] == "needs_mapping"
|
||||
err = json.loads(res["rar_error"])
|
||||
# Cheia originala pastrata
|
||||
assert "auto_send" in err
|
||||
# 3 niveluri prezente
|
||||
assert err["cod"] == "AUTO_SEND_OPRIT"
|
||||
assert err["problema"]
|
||||
assert err["cauza"]
|
||||
assert err["fix"]
|
||||
|
||||
|
||||
def test_needs_data_pass_through(client):
|
||||
"""VIN invalid -> needs_data; rar_error = array cu erori care au cod/problema/fix (US-002)."""
|
||||
import json
|
||||
from app.mapping import classify_prezentare
|
||||
|
||||
content = {
|
||||
"vin": "VIN_INVALID_XXXXXXXXX", # nu trece regex
|
||||
"nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": "123456",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}],
|
||||
}
|
||||
mapping = {}
|
||||
mapping_meta = {}
|
||||
res = classify_prezentare(content, mapping, mapping_meta)
|
||||
assert res["status"] == "needs_data"
|
||||
erori = json.loads(res["rar_error"])
|
||||
assert isinstance(erori, list)
|
||||
assert len(erori) >= 1
|
||||
# Fiecare eroare are cele 3 niveluri (pass-through US-002)
|
||||
for e in erori:
|
||||
assert "cod" in e, f"lipseste 'cod' in {e}"
|
||||
assert "problema" in e, f"lipseste 'problema' in {e}"
|
||||
assert "fix" in e, f"lipseste 'fix' in {e}"
|
||||
|
||||
@@ -157,3 +157,44 @@ def test_shape_invalid_422(client):
|
||||
for err in body.get("detail", []):
|
||||
assert "input" not in err
|
||||
assert "ctx" not in err
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# US-003: 3 niveluri in raspunsul /valideaza #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_erori_au_3niveluri(client):
|
||||
"""/valideaza cu VIN invalid -> erori[i] au cod/problema/cauza/fix (pass-through US-002)."""
|
||||
r = client.post("/v1/prezentari/valideaza", json=_body_v(vin="WVWZZZ1OZIQ45678"))
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status_estimat"] == "needs_data"
|
||||
erori = res["erori"]
|
||||
assert len(erori) >= 1
|
||||
for e in erori:
|
||||
assert "cod" in e, f"lipseste 'cod' in {e}"
|
||||
assert "problema" in e, f"lipseste 'problema' in {e}"
|
||||
assert "cauza" in e, f"lipseste 'cauza' in {e}"
|
||||
assert "fix" in e, f"lipseste 'fix' in {e}"
|
||||
|
||||
|
||||
def test_nemapate_au_3niveluri(client):
|
||||
"""/valideaza cu cod_op nemapat -> nemapate[i] au cod_op_service+denumire PASTRATE
|
||||
+ cod==COD_NEMAPAT + cele 3 niveluri."""
|
||||
prez = _prez()
|
||||
prez["prestatii"] = [{"cod_op_service": "OP_TEST_NEMAPAT", "denumire": "Operatie test"}]
|
||||
r = client.post("/v1/prezentari/valideaza", json={"prezentari": [prez]})
|
||||
assert r.status_code == 200
|
||||
res = r.json()["results"][0]
|
||||
assert res["status_estimat"] == "needs_mapping"
|
||||
nemapate = res["nemapate"]
|
||||
assert len(nemapate) == 1
|
||||
n = nemapate[0]
|
||||
# Campuri originale pastrate
|
||||
assert n["cod_op_service"] == "OP_TEST_NEMAPAT"
|
||||
assert n["denumire"] == "Operatie test"
|
||||
# 3 niveluri adaugate
|
||||
assert n["cod"] == "COD_NEMAPAT"
|
||||
assert n["problema"]
|
||||
assert n["cauza"]
|
||||
assert n["fix"]
|
||||
|
||||
@@ -137,3 +137,114 @@ def test_b64image_valid_ok():
|
||||
def test_erori_multiple_cumulate():
|
||||
errors = validate_prezentare(_base(vin="BAD", nr_inmatriculare="X-Y", data_prestatie="2024-01-01"))
|
||||
assert {"vin", "nr_inmatriculare", "data_prestatie"} <= _fields(errors)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-002: 3 niveluri — coduri stabile, forma aditiva, back-compat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app import errors as err_mod # noqa: E402
|
||||
|
||||
|
||||
def test_vin_invalid_are_3niveluri():
|
||||
"""VIN cu O/I/Q => eroarea are cod, problema, fix, field, message neschimbat."""
|
||||
errs = validate_prezentare(_base(vin="WVWZZZ1OZAW000123"))
|
||||
vin_errs = [e for e in errs if e.get("field") == "vin"]
|
||||
assert vin_errs, "Trebuie cel putin o eroare cu field==vin"
|
||||
e = vin_errs[0]
|
||||
assert e["cod"] == "VIN_FORMAT"
|
||||
assert e["problema"], "problema trebuie sa fie ne-goala"
|
||||
assert e["fix"], "fix trebuie sa fie ne-gol"
|
||||
assert e["field"] == "vin"
|
||||
# message trebuie sa fie mesajul existent (back-compat)
|
||||
assert "17" in e["message"] or "O, I, Q" in e["message"]
|
||||
|
||||
|
||||
def test_data_prea_veche_cod():
|
||||
errs = validate_prezentare(_base(data_prestatie="2024-11-30"))
|
||||
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||
assert dp
|
||||
assert dp[0]["cod"] == "DATA_PREA_VECHE"
|
||||
|
||||
|
||||
def test_data_viitor_cod():
|
||||
from datetime import timedelta
|
||||
maine = (today_bucuresti() + timedelta(days=1)).isoformat()
|
||||
errs = validate_prezentare(_base(data_prestatie=maine))
|
||||
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||
assert dp
|
||||
assert dp[0]["cod"] == "DATA_VIITOR"
|
||||
|
||||
|
||||
def test_data_format_cod():
|
||||
errs = validate_prezentare(_base(data_prestatie="15-06-2026"))
|
||||
dp = [e for e in errs if e.get("field") == "data_prestatie"]
|
||||
assert dp
|
||||
assert dp[0]["cod"] == "DATA_FORMAT"
|
||||
|
||||
|
||||
def test_odometru_initial_lipsa_cod():
|
||||
errs = validate_prezentare(_base(prestatii=[{"cod_prestatie": "R-ODO"}]))
|
||||
oi = [e for e in errs if e.get("field") == "odometru_initial"]
|
||||
assert oi
|
||||
assert oi[0]["cod"] == "ODOMETRU_INITIAL_LIPSA"
|
||||
|
||||
|
||||
def test_odometru_ordine_cod():
|
||||
c = _base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000")
|
||||
errs = validate_prezentare(c)
|
||||
oi = [e for e in errs if e.get("field") == "odometru_initial" and e.get("cod") == "ODOMETRU_INITIAL_ORDINE"]
|
||||
assert oi, "Trebuie eroare cu cod ODOMETRU_INITIAL_ORDINE"
|
||||
|
||||
|
||||
def test_prestatii_goale_cod():
|
||||
errs = validate_prezentare(_base(prestatii=[]))
|
||||
pr = [e for e in errs if e.get("field") == "prestatii"]
|
||||
assert pr
|
||||
assert pr[0]["cod"] == "PRESTATII_GOALE"
|
||||
|
||||
|
||||
def test_b64_invalid_cod():
|
||||
errs = validate_prezentare(_base(b64_image="@@@not-base64@@@"))
|
||||
b = [e for e in errs if e.get("field") == "b64_image"]
|
||||
assert b
|
||||
assert b[0]["cod"] == "B64_INVALID"
|
||||
|
||||
|
||||
def test_back_compat_field_message():
|
||||
"""Fiecare eroare are inca field + message (forma veche)."""
|
||||
errs = validate_prezentare(_base(
|
||||
vin="BAD",
|
||||
nr_inmatriculare="X-Y",
|
||||
data_prestatie="2024-01-01",
|
||||
odometru_final="abc",
|
||||
prestatii=[],
|
||||
))
|
||||
for e in errs:
|
||||
assert "field" in e, f"Lipseste 'field' in {e}"
|
||||
assert "message" in e, f"Lipseste 'message' in {e}"
|
||||
assert e["message"], f"'message' gol in {e}"
|
||||
|
||||
|
||||
def test_toate_codurile_in_catalog():
|
||||
"""Fiecare cod emis de validate_prezentare exista in errors.CATALOG."""
|
||||
from datetime import timedelta
|
||||
|
||||
cazuri = [
|
||||
_base(vin="WVWZZZ1OZAW000123"), # VIN_FORMAT
|
||||
_base(nr_inmatriculare="X-Y"), # NR_INMATRICULARE_FORMAT
|
||||
_base(data_prestatie="15-06-2026"), # DATA_FORMAT
|
||||
_base(data_prestatie="2024-11-30"), # DATA_PREA_VECHE
|
||||
_base(data_prestatie=(today_bucuresti() + timedelta(days=1)).isoformat()), # DATA_VIITOR
|
||||
_base(odometru_final="abc"), # ODOMETRU_FINAL_FORMAT
|
||||
_base(prestatii=[{"cod_prestatie": "R-ODO"}]), # ODOMETRU_INITIAL_LIPSA
|
||||
_base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="abc"), # ODOMETRU_INITIAL_FORMAT
|
||||
_base(prestatii=[{"cod_prestatie": "R-ODO"}], odometru_initial="200000", odometru_final="100000"), # ODOMETRU_INITIAL_ORDINE
|
||||
_base(prestatii=[]), # PRESTATII_GOALE
|
||||
_base(b64_image="@@@not-base64@@@"), # B64_INVALID
|
||||
]
|
||||
for caz in cazuri:
|
||||
errs = validate_prezentare(caz)
|
||||
for e in errs:
|
||||
if "cod" in e:
|
||||
assert e["cod"] in err_mod.CATALOG, f"Cod {e['cod']!r} absent din CATALOG"
|
||||
|
||||
285
tests/test_web_erori.py
Normal file
285
tests/test_web_erori.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Teste US-006 (PRD 5.4): Componenta UI de eroare pe 3 niveluri.
|
||||
|
||||
Pasul A (RED): teste scrise inainte de implementare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste pure pentru parse_erori (fara HTTP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.web.labels import parse_erori # noqa: E402
|
||||
|
||||
|
||||
def test_parse_erori_array_3niveluri():
|
||||
"""Array imbogatit cu cod/problema/cauza/fix -> lista cu toate 3 nivelurile."""
|
||||
rar_error = json.dumps([
|
||||
{
|
||||
"field": "vin",
|
||||
"cod": "VIN_FORMAT",
|
||||
"problema": "VIN invalid",
|
||||
"cauza": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||
"fix": "Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule.",
|
||||
"message": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||
}
|
||||
])
|
||||
rezultat = parse_erori(rar_error)
|
||||
assert len(rezultat) == 1
|
||||
e = rezultat[0]
|
||||
assert e["problema"] == "VIN invalid"
|
||||
assert "17" in e["cauza"]
|
||||
assert e["fix"] # non-gol
|
||||
assert e.get("field") == "vin"
|
||||
|
||||
|
||||
def test_parse_erori_unmapped():
|
||||
"""Dict unmapped 3-niveluri (cod=COD_NEMAPAT) -> 1 element corect."""
|
||||
rar_error = json.dumps({
|
||||
"cod": "COD_NEMAPAT",
|
||||
"problema": "Lipseste codul RAR al operatiei",
|
||||
"cauza": "Codul OP-99 nu are mapare RAR.",
|
||||
"fix": "Alege codul RAR pentru aceasta operatie in tab-ul Mapari.",
|
||||
"unmapped": [{"cod_op_service": "OP-99", "denumire": "Operatie test"}],
|
||||
})
|
||||
rezultat = parse_erori(rar_error)
|
||||
assert len(rezultat) == 1
|
||||
e = rezultat[0]
|
||||
assert e["problema"] == "Lipseste codul RAR al operatiei"
|
||||
assert e["fix"]
|
||||
|
||||
|
||||
def test_parse_erori_creds():
|
||||
"""Dict cu cod=RAR_CREDS_INVALIDE -> 1 element corect."""
|
||||
rar_error = json.dumps({
|
||||
"cod": "RAR_CREDS_INVALIDE",
|
||||
"problema": "Credentiale RAR invalide",
|
||||
"cauza": "Autentificarea la RAR a esuat (401).",
|
||||
"fix": "Verifica email-ul si parola contului RAR in tab-ul Cont.",
|
||||
})
|
||||
rezultat = parse_erori(rar_error)
|
||||
assert len(rezultat) == 1
|
||||
e = rezultat[0]
|
||||
assert e["problema"] == "Credentiale RAR invalide"
|
||||
assert e["fix"]
|
||||
|
||||
|
||||
def test_parse_erori_forma_veche_si_corupt():
|
||||
"""Forma veche [{ field, message }], string plain, None, invalid -> degradeaza fara exceptie."""
|
||||
# Forma veche: lista cu field+message dar fara cod
|
||||
vechi = json.dumps([{"field": "vin", "message": "VIN invalid"}])
|
||||
r = parse_erori(vechi)
|
||||
assert isinstance(r, list)
|
||||
assert len(r) >= 1
|
||||
# Nu arunca pentru niciun element
|
||||
for e in r:
|
||||
assert "problema" in e
|
||||
|
||||
# String plain
|
||||
r2 = parse_erori("Eroare generica de la RAR")
|
||||
assert isinstance(r2, list)
|
||||
assert len(r2) >= 1
|
||||
assert r2[0]["problema"]
|
||||
|
||||
# None
|
||||
r3 = parse_erori(None)
|
||||
assert isinstance(r3, list)
|
||||
assert r3 == []
|
||||
|
||||
# JSON corupt
|
||||
r4 = parse_erori("{invalid json[[[")
|
||||
assert isinstance(r4, list)
|
||||
assert len(r4) >= 1
|
||||
# Nu arunca
|
||||
|
||||
# Dict fara cod (forma veche dict)
|
||||
r5 = parse_erori(json.dumps({"auto_send": "cod-abc", "motiv": "auto_send oprit"}))
|
||||
assert isinstance(r5, list)
|
||||
assert len(r5) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "erori.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _csv_bytes(rows: list[dict]) -> bytes:
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _seed_mapping(conn=None):
|
||||
"""Mapeaza OP-1 -> R-FRANE (cont dev id=1)."""
|
||||
from app.db import get_connection
|
||||
c = conn or get_connection()
|
||||
try:
|
||||
c.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')")
|
||||
c.execute(
|
||||
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
|
||||
"VALUES (1, 'OP-1', 'R-FRANE', 1)"
|
||||
)
|
||||
c.commit()
|
||||
finally:
|
||||
if conn is None:
|
||||
c.close()
|
||||
|
||||
|
||||
def _creeaza_submission_needs_data(client, fix_text=None):
|
||||
"""Creeaza un submission in starea needs_data si returneaza id-ul."""
|
||||
import json as _json
|
||||
from app.db import get_connection
|
||||
|
||||
_seed_mapping()
|
||||
|
||||
# Insereaza direct un submission needs_data cu rar_error 3-niveluri
|
||||
fix_folosit = fix_text or "Verifica VIN-ul pe talon (pozitia E); 17 caractere majuscule."
|
||||
rar_error_3n = _json.dumps([{
|
||||
"field": "vin",
|
||||
"cod": "VIN_FORMAT",
|
||||
"problema": "VIN invalid",
|
||||
"cauza": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||
"fix": fix_folosit,
|
||||
"message": "VIN-ul are 16 caractere; RAR cere exact 17.",
|
||||
}])
|
||||
|
||||
payload = _json.dumps({
|
||||
"vin": "VIN16CARACT000000",
|
||||
"nr_inmatriculare": "B001TST",
|
||||
"data_prestatie": "2026-06-15",
|
||||
"odometru_final": 145000,
|
||||
"cod_prestatie": "R-FRANE",
|
||||
"prestatii": [{"cod_prestatie": "R-FRANE"}],
|
||||
})
|
||||
idem_key = f"test-erori-{fix_folosit[:20]}"
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO submissions
|
||||
(account_id, idempotency_key, payload_json, status, rar_error, retry_count)
|
||||
VALUES (1, ?, ?, 'needs_data', ?, 0)""",
|
||||
(idem_key, payload, rar_error_3n)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_detaliu_afiseaza_fix(client):
|
||||
"""Fragmentul de detaliu al unui submission needs_data contine textul fix-ului."""
|
||||
sub_id = _creeaza_submission_needs_data(client, "Verifica VIN-ul pe talon (pozitia E)")
|
||||
resp = client.get(f"/_fragments/trimitere/{sub_id}")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
# Fix-ul trebuie sa apara in HTML
|
||||
assert "pozitia E" in html, (
|
||||
f"HTML-ul detaliu nu contine fix-ul ('pozitia E'). Primii 2000 chars:\n{html[:2000]}"
|
||||
)
|
||||
|
||||
|
||||
def _import_preview_cu_vin_invalid(client):
|
||||
"""Efectueaza un import cu un rand cu VIN invalid si returneaza HTML-ul preview."""
|
||||
_seed_mapping()
|
||||
rows = [
|
||||
# rand cu VIN de 16 caractere (invalid, trebuie 17) -> needs_data
|
||||
{"VIN": "VIN16CARACT00000", "Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026", "Odometru final": "145000", "Operatie": "OP-1"},
|
||||
]
|
||||
data = _csv_bytes(rows)
|
||||
|
||||
r = client.post("/_import/upload", files={"file": ("test.csv", data, "text/csv")})
|
||||
assert r.status_code == 200
|
||||
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
|
||||
assert m, f"Nu am gasit import_id. Raspuns: {r.text[:1000]}"
|
||||
import_id = int(m.group(1))
|
||||
|
||||
r = client.post(f"/_import/{import_id}/mapare-coloane", data={
|
||||
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
|
||||
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
|
||||
"format_data": "DD.MM.YYYY",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
return r.text
|
||||
|
||||
|
||||
def test_preview_rand_per_camp_fix(client):
|
||||
"""Preview rand needs_data -> HTML contine fix-ul per camp (VIN)."""
|
||||
html = _import_preview_cu_vin_invalid(client)
|
||||
# Randul trebuie sa fie needs_data
|
||||
assert "needs_data" in html or "needs_review" in html or "VIN" in html.upper(), (
|
||||
f"HTML-ul preview nu contine starea asteptata. Primii 2000 chars:\n{html[:2000]}"
|
||||
)
|
||||
# Fix-ul pentru VIN trebuie sa apara in preview
|
||||
assert "talon" in html.lower() or "majuscule" in html.lower() or "pozitia" in html.lower(), (
|
||||
f"HTML-ul preview nu contine fix-ul VIN. Primii 3000 chars:\n{html[:3000]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste noi (BUG 1 + BUG 2) — adaugate TDD-style (RED inainte de fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.web.labels import motiv_uman # noqa: E402
|
||||
from app.errors import eroare # noqa: E402
|
||||
|
||||
|
||||
def test_motiv_uman_creds_3niveluri():
|
||||
"""BUG 1: motiv_uman pe dict 3-niveluri (RAR_CREDS_INVALIDE) -> problema, NU text garbled."""
|
||||
rar_err = json.dumps(eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"))
|
||||
rezultat = motiv_uman("error", rar_err)
|
||||
# Nu trebuie sa contina "field:" / "cod:" (text garbled)
|
||||
assert "field:" not in rezultat, f"Text garbled cu 'field:': {rezultat!r}"
|
||||
assert "cod:" not in rezultat, f"Text garbled cu 'cod:': {rezultat!r}"
|
||||
# Trebuie sa returneze textul 'problema' (primul nivel)
|
||||
data = json.loads(rar_err)
|
||||
problema = data.get("problema") or ""
|
||||
assert problema, "eroare() nu a returnat 'problema' — verifica error_codes.py"
|
||||
assert rezultat == problema[:200], (
|
||||
f"Asteptat {problema[:200]!r}, obtinut {rezultat!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_motiv_uman_unmapped_neschimbat():
|
||||
"""Ramura unmapped inca functioneaza dupa adaugarea ramurii 3-niveluri."""
|
||||
rar_err = json.dumps({"unmapped": [{"cod_op_service": "OP-99", "denumire": "Test"}]})
|
||||
rezultat = motiv_uman("needs_mapping", rar_err)
|
||||
assert rezultat.startswith("Cod RAR lipsa pentru:"), (
|
||||
f"Ramura unmapped regresta. Obtinut: {rezultat!r}"
|
||||
)
|
||||
assert "OP-99" in rezultat
|
||||
|
||||
|
||||
def test_parse_erori_gol_returneaza_lista_goala():
|
||||
"""BUG 2: parse_erori pe dict/lista goala -> [], nu 1 element cu problema=''."""
|
||||
r1 = parse_erori("{}")
|
||||
assert r1 == [], f"parse_erori('{{}}') -> {r1!r}, asteptat []"
|
||||
|
||||
r2 = parse_erori("[{}]")
|
||||
assert r2 == [], f"parse_erori('[{{}}]') -> {r2!r}, asteptat []"
|
||||
180
tests/test_web_import_erori.py
Normal file
180
tests/test_web_import_erori.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Teste US-007 (PRD 5.4): erori de import pe 3 niveluri in interfata web.
|
||||
|
||||
Verifica ca fragmentele HTML intoarse de rutele web /_import/* contin
|
||||
textele structurate (problema + fix) din catalog pentru erorile de upload
|
||||
si de mapare coloane.
|
||||
|
||||
Rutele testate sunt WEB (HTML), nu API JSON — raspunsul este HTML fragment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixture client cu WEB_AUTH_REQUIRED=false (dev mode, cont 1 implicit) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client FastAPI cu DB temporara izolata, auth web dezactivat (dev mode)."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "erori3n.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
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()
|
||||
reset_cache()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpere #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _make_xlsx_prea_mare(n_randuri: int = 5001) -> bytes:
|
||||
"""Xlsx cu mai mult de 5000 de randuri de date (declanseaza FileTooLarge)."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"])
|
||||
for i in range(n_randuri):
|
||||
ws.append([
|
||||
f"WVWZZZ1KZA{i:07d}",
|
||||
f"B{i:04d}TST",
|
||||
"2026-06-15",
|
||||
str(100000 + i),
|
||||
"Revizie",
|
||||
])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _upload_web(client: TestClient, data: bytes, filename: str = "test.xlsx") -> object:
|
||||
"""POST pe ruta web de upload (intoarce HTML fragment)."""
|
||||
return client.post(
|
||||
"/_import/upload",
|
||||
files={"file": (filename, io.BytesIO(data), "application/octet-stream")},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Upload fisier prea mare → fragment cu fix din IMPORT_FISIER_PREA_MARE #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_upload_fisier_prea_mare_3niveluri(client):
|
||||
"""Upload xlsx >5000 randuri → fragment HTML contine textul fix din catalog.
|
||||
|
||||
Textul asteptat din CATALOG['IMPORT_FISIER_PREA_MARE']['fix']:
|
||||
'Imparte fisierul in bucati de maxim 5000 de randuri si incarca-le pe rand.'
|
||||
"""
|
||||
from app.errors import CATALOG
|
||||
fix_asteptat = CATALOG["IMPORT_FISIER_PREA_MARE"]["fix"]
|
||||
|
||||
data = _make_xlsx_prea_mare(5001)
|
||||
r = _upload_web(client, data, "mare.xlsx")
|
||||
|
||||
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||
html = r.text
|
||||
assert fix_asteptat in html, (
|
||||
f"Textul fix din IMPORT_FISIER_PREA_MARE nu apare in fragmentul HTML.\n"
|
||||
f"Asteptat: {fix_asteptat!r}\n"
|
||||
f"Fragment (primii 800 chars): {html[:800]}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Upload fisier nerecunoscut → fragment cu fix din IMPORT_FISIER_NERECUNOSCUT
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_upload_fisier_nerecunoscut_3niveluri(client):
|
||||
"""Upload bytes invalizi (nu e xlsx/csv valid) → fragment HTML cu fix din catalog.
|
||||
|
||||
Textul asteptat din CATALOG['IMPORT_FISIER_NERECUNOSCUT']['fix']:
|
||||
'Incarca un fisier .xlsx sau .csv valid.'
|
||||
"""
|
||||
from app.errors import CATALOG
|
||||
fix_asteptat = CATALOG["IMPORT_FISIER_NERECUNOSCUT"]["fix"]
|
||||
problema_asteptata = CATALOG["IMPORT_FISIER_NERECUNOSCUT"]["problema"]
|
||||
|
||||
# Bytes invalizi: nu este un zip/xlsx, nu este text CSV
|
||||
date_invalide = b"\x00\x01\x02\x03\x04\x05binar_junk_non_xlsx"
|
||||
r = _upload_web(client, date_invalide, "test.xlsx")
|
||||
|
||||
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||
html = r.text
|
||||
assert fix_asteptat in html, (
|
||||
f"Textul fix din IMPORT_FISIER_NERECUNOSCUT nu apare in fragmentul HTML.\n"
|
||||
f"Asteptat: {fix_asteptat!r}\n"
|
||||
f"Fragment (primii 800 chars): {html[:800]}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Mapare coloane cu JSON invalid → fragment cu fix din COLOANE_FORMAT_JSON #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_mapcoloane_format_json_3niveluri(client):
|
||||
"""POST direct pe ruta de mapare coloane cu corp JSON invalid → fragment cu fix din catalog.
|
||||
|
||||
Textul asteptat din CATALOG['COLOANE_FORMAT_JSON']['fix']:
|
||||
'Verifica sintaxa JSON a maparii de coloane (ghilimele duble, acolade inchise corect).'
|
||||
|
||||
Ruta /_import/{id}/mapare-coloane accepta form data. Codul de test simuleaza
|
||||
un batch_id invalid (0) pentru a forta ramura de eroare, sau injecteaza direct
|
||||
un batch_id valid cu JSON invalid in campul de mapare. Deoarece ruta nu primeste
|
||||
JSON direct, testam ramura din routes.py unde se face json.dumps si se valideaza
|
||||
manual, sau modificam testul sa plaseze un batch valid si sa trimita date malformate.
|
||||
|
||||
Strategia: upload un fisier valid, obtine import_id, trimite un form cu valoare
|
||||
json invalida in campul coloane (via Content-Type: application/json intentionat gresit
|
||||
sau via parametru form malformat). In implementarea din routes.py, eroarea
|
||||
COLOANE_FORMAT_JSON este randata cand json.loads esueaza pe un camp special.
|
||||
"""
|
||||
from app.errors import CATALOG
|
||||
fix_asteptat = CATALOG["COLOANE_FORMAT_JSON"]["fix"]
|
||||
|
||||
# Primul pas: upload un fisier valid pentru a obtine un import_id real
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["VIN", "Nr", "Data", "KM", "Op"])
|
||||
ws.append(["WVWZZZ1KZAW000123", "B001TST", "2026-06-15", "100000", "Revizie"])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
xlsx_data = buf.getvalue()
|
||||
|
||||
r_upload = _upload_web(client, xlsx_data, "test.xlsx")
|
||||
assert r_upload.status_code == 200, r_upload.text[:300]
|
||||
|
||||
# Extrage import_id din raspuns
|
||||
import re
|
||||
m = re.search(r"/_import/(\d+)/mapare-coloane", r_upload.text)
|
||||
assert m, f"Nu s-a gasit import_id in raspuns: {r_upload.text[:500]}"
|
||||
import_id = int(m.group(1))
|
||||
|
||||
# Trimite un request pe ruta de mapare coloane cu Content-Type: application/json
|
||||
# si corp JSON invalid → ruta ar trebui sa intoarca eroarea COLOANE_FORMAT_JSON
|
||||
r = client.post(
|
||||
f"/_import/{import_id}/mapare-coloane",
|
||||
content=b"{invalid json}",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, f"Asteptat 200, primit {r.status_code}: {r.text[:300]}"
|
||||
html = r.text
|
||||
assert fix_asteptat in html, (
|
||||
f"Textul fix din COLOANE_FORMAT_JSON nu apare in fragmentul HTML.\n"
|
||||
f"Asteptat: {fix_asteptat!r}\n"
|
||||
f"Fragment (primii 800 chars): {html[:800]}"
|
||||
)
|
||||
245
tests/test_worker_rar_errors.py
Normal file
245
tests/test_worker_rar_errors.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Teste US-004 — erorile RAR (400/401) imbracate pe 3 niveluri in worker.
|
||||
|
||||
process_one cu 400 -> rar_error superset [{field,message,cod,problema,cauza,fix}]
|
||||
run() loop cu 401 la login -> submission error, rar_error JSON 3-niveluri RAR_CREDS_INVALIDE
|
||||
Clasificare transient/terminal neschimbata.
|
||||
Fara echo de credentiale in rar_error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.rar_client import RarAuthError, RarError
|
||||
|
||||
|
||||
# --- Fixtures DB (pattern din test_worker_reconcile.py) ---
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
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.db import get_connection, init_db
|
||||
init_db()
|
||||
conn = get_connection()
|
||||
settings = get_settings()
|
||||
yield conn, settings
|
||||
conn.close()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
_CONTENT = {
|
||||
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
|
||||
"data_prestatie": "2026-06-15", "odometru_final": "123456",
|
||||
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null",
|
||||
}
|
||||
|
||||
|
||||
def _insert(conn, status="queued", content=None, retry_count=0):
|
||||
content = content or _CONTENT
|
||||
cur = conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, status, payload_json, retry_count) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(f"key-{os.urandom(4).hex()}", status, json.dumps(content), retry_count),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def _row(conn, sid):
|
||||
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
||||
|
||||
|
||||
class FakeRar:
|
||||
"""RAR mock cu comportament configurabil."""
|
||||
def __init__(self, *, finalizate=None, post_result=None, post_exc=None):
|
||||
self.finalizate = finalizate or []
|
||||
self.post_result = post_result if post_result is not None else {"id": 1000}
|
||||
self.post_exc = post_exc
|
||||
self.post_calls = 0
|
||||
self.finalizate_calls = 0
|
||||
|
||||
def get_finalizate(self, token):
|
||||
self.finalizate_calls += 1
|
||||
return self.finalizate
|
||||
|
||||
def post_prezentare(self, token, payload):
|
||||
self.post_calls += 1
|
||||
if self.post_exc is not None:
|
||||
raise self.post_exc
|
||||
return self.post_result
|
||||
|
||||
|
||||
# --- Teste US-004 ---
|
||||
|
||||
def test_rar_400_stocheaza_3niveluri(env):
|
||||
"""RAR 400 cu field_errors -> rar_error superset array cu cod/problema/fix/message."""
|
||||
from app.worker.__main__ import process_one
|
||||
conn, settings = env
|
||||
sid = _insert(conn)
|
||||
exc = RarError("Validare esuata", status_code=400,
|
||||
field_errors=[{"field": "vin", "message": "VIN invalid la RAR"}])
|
||||
rar = FakeRar(post_exc=exc)
|
||||
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||
|
||||
assert out == "needs_data"
|
||||
row = _row(conn, sid)
|
||||
assert row["status"] == "needs_data"
|
||||
assert row["rar_status_code"] == 400
|
||||
|
||||
erori = json.loads(row["rar_error"])
|
||||
assert isinstance(erori, list)
|
||||
assert len(erori) == 1
|
||||
|
||||
elem = erori[0]
|
||||
# Superset: field + message (vechi) + 3 niveluri (noi)
|
||||
assert elem["field"] == "vin"
|
||||
assert elem["message"] == "VIN invalid la RAR" # passthrough exact mesaj RAR
|
||||
assert elem["cod"] == "RAR_VALIDARE"
|
||||
assert elem["problema"] # ne-gol
|
||||
assert elem["fix"] # ne-gol
|
||||
assert elem["cauza"] == "VIN invalid la RAR" # cauza = mesajul RAR exact
|
||||
|
||||
|
||||
def test_rar_400_fara_field_errors(env):
|
||||
"""RAR 400 cu field_errors=[] -> array cu 1 element RAR_VALIDARE, cauza din str(exc)."""
|
||||
from app.worker.__main__ import process_one
|
||||
conn, settings = env
|
||||
sid = _insert(conn)
|
||||
exc = RarError("Eroare generica RAR", status_code=400, field_errors=[])
|
||||
rar = FakeRar(post_exc=exc)
|
||||
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||
|
||||
assert out == "needs_data"
|
||||
erori = json.loads(_row(conn, sid)["rar_error"])
|
||||
assert isinstance(erori, list)
|
||||
assert len(erori) == 1
|
||||
|
||||
elem = erori[0]
|
||||
assert elem["cod"] == "RAR_VALIDARE"
|
||||
assert elem["cauza"] == "Eroare generica RAR" # str(exc)
|
||||
assert elem["problema"]
|
||||
assert elem["fix"]
|
||||
|
||||
|
||||
def test_rar_401_creds_3niveluri(env):
|
||||
"""Login esueaza cu RarAuthError -> submission error, rar_error JSON 3-niveluri RAR_CREDS_INVALIDE."""
|
||||
from app.worker.__main__ import mark, process_one
|
||||
conn, settings = env
|
||||
|
||||
# Simulam fluxul din run(): login esueaza cu RarAuthError, workerul marcheaza direct.
|
||||
# Testam handler-ul login din run() prin apel direct la mark() asa cum il face workerul.
|
||||
sid = _insert(conn, status="sending")
|
||||
|
||||
# Apelam direct mark() cu JSON-ul pe care workerul il va produce (dupa implementare)
|
||||
from app.errors import eroare
|
||||
err_obj = eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide")
|
||||
err_json = json.dumps(err_obj, ensure_ascii=False)
|
||||
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||
|
||||
row = _row(conn, sid)
|
||||
assert row["status"] == "error"
|
||||
err = json.loads(row["rar_error"])
|
||||
assert err["cod"] == "RAR_CREDS_INVALIDE"
|
||||
assert err["problema"]
|
||||
assert err["fix"]
|
||||
assert err["cauza"] == "credentiale RAR invalide"
|
||||
|
||||
|
||||
def test_rar_401_login_in_run_loop(env):
|
||||
"""run() loop: RarAuthError la login -> submission 'error' cu rar_error JSON 3-niveluri."""
|
||||
from app.worker.__main__ import AccountSessions, _creds_for, _creds_from_account, mark, requeue_with_backoff
|
||||
conn, settings = env
|
||||
sid = _insert(conn, status="queued")
|
||||
|
||||
# Simuleaza comportamentul din run() pentru blocul try: sessions.get_token cu RarAuthError
|
||||
# Acesta e acelasi cod pe care il implementam; testam efectul final pe DB.
|
||||
|
||||
class FakeSessionsLoginFail:
|
||||
def get_token(self, conn, account_id, creds):
|
||||
raise RarAuthError("Credentiale RAR invalide", status_code=401)
|
||||
|
||||
import json as _json
|
||||
from app.errors import eroare as _eroare
|
||||
|
||||
try:
|
||||
FakeSessionsLoginFail().get_token(conn, 1, {"email": "x@x.com", "password": "secret123"})
|
||||
except RarAuthError:
|
||||
err_json = _json.dumps(_eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide"), ensure_ascii=False)
|
||||
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||
|
||||
row = _row(conn, sid)
|
||||
assert row["status"] == "error"
|
||||
err = _json.loads(row["rar_error"])
|
||||
assert err["cod"] == "RAR_CREDS_INVALIDE"
|
||||
assert err["problema"]
|
||||
assert err["fix"]
|
||||
# Fara retry: statusul ramane error (nu queued)
|
||||
assert row["retry_count"] == 0
|
||||
|
||||
|
||||
def test_clasificare_transient_neschimbata(env):
|
||||
"""5xx ramane transient: requeue cu retry, NU error terminal."""
|
||||
from app.worker.__main__ import process_one
|
||||
conn, settings = env
|
||||
sid = _insert(conn)
|
||||
rar = FakeRar(finalizate=[], post_exc=RarError("server error", status_code=503))
|
||||
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
|
||||
|
||||
assert out == "requeued"
|
||||
row = _row(conn, sid)
|
||||
assert row["status"] == "queued" # requeue, nu error
|
||||
assert int(row["retry_count"]) == 1 # a incrementat retry
|
||||
|
||||
# Timeout httpx -> de asemenea transient
|
||||
sid2 = _insert(conn)
|
||||
rar2 = FakeRar(finalizate=[], post_exc=httpx.ConnectTimeout("timeout"))
|
||||
out2 = process_one(conn, settings, rar2, "tok", {"id": sid2, "content": _CONTENT})
|
||||
assert out2 == "requeued"
|
||||
assert _row(conn, sid2)["status"] == "queued"
|
||||
|
||||
|
||||
def test_fara_echo_creds(env):
|
||||
"""rar_error nu contine valori concrete de credentiale (email/parola reala).
|
||||
|
||||
Textul 'parola' poate aparea in mesajele de ajutor (fix) — e de asteptat.
|
||||
Verificam ca parola/email-ul concret al utilizatorului NU apare in rar_error.
|
||||
"""
|
||||
from app.worker.__main__ import mark, process_one
|
||||
conn, settings = env
|
||||
sid = _insert(conn, status="sending")
|
||||
|
||||
# O parola concreta fictiva care NU trebuie sa apara niciodata in rar_error
|
||||
parola_concreta = "SuperSecret$789!"
|
||||
email_concret = "user@exemplu.ro"
|
||||
|
||||
from app.errors import eroare
|
||||
# Simuleaza eroarea 401 — cauza e generica, nu contine parola concreta
|
||||
err_obj = eroare("RAR_CREDS_INVALIDE", cauza="credentiale RAR invalide")
|
||||
err_json = json.dumps(err_obj, ensure_ascii=False)
|
||||
mark(conn, sid, "error", rar_status_code=401, rar_error=err_json)
|
||||
|
||||
row = _row(conn, sid)
|
||||
rar_error_str = row["rar_error"]
|
||||
# Parola concreta si email-ul concret NU trebuie sa apara
|
||||
assert parola_concreta not in rar_error_str
|
||||
assert email_concret not in rar_error_str
|
||||
# Campul "password" (cheia JSON) nu trebuie sa apara — ar insemna ca obiectul creds a fost serializat
|
||||
assert '"password"' not in rar_error_str
|
||||
|
||||
# Verifica si pentru 400 — mesajul RAR nu contine parola utilizatorului
|
||||
sid2 = _insert(conn)
|
||||
exc = RarError("Validare", status_code=400, field_errors=[{"field": "vin", "message": "bad vin"}])
|
||||
rar = FakeRar(post_exc=exc)
|
||||
process_one(conn, settings, rar, "tok", {"id": sid2, "content": _CONTENT})
|
||||
rar_error_400 = _row(conn, sid2)["rar_error"]
|
||||
assert parola_concreta not in rar_error_400
|
||||
assert '"password"' not in rar_error_400
|
||||
Reference in New Issue
Block a user