Files
rar-autopass/tests/test_web_corectie_prestatii.py
Claude Agent 3fc53534e2 feat(5.15+5.14): CLOSE — fix-uri code-review + embeddings functional
5.15 (propagare design + dashboard editare) si 5.14 (mapare LLM distilata)
inchise dupa /code-review high. 8 buguri reparate TDD:

- HIGH modal nu se deschidea pe randul slim (base.html: trimitere-slim)
- HIGH /repune trunchia prestatii (declaratie incompleta la RAR) -> iterare
  peste existing, codes pozitional
- HIGH embeddings incarca model ~230MB degeaba pe corpus gol -> poarta has_corpus()
- HIGH picker chips gol pe re-render eroare -> conn/account_id pe toate ramurile
- MED obs re-derivat dupa stergere explicita -> _merge_override pastreaza obs=''
- MED mapare salvata fara denumire poluă GOLD -> _record_gold_validation guard
- MED typo nome_prestatie -> nume_prestatie in select /repune
- MED bucketare timp +3h gresita iarna -> SQLite localtime + TZ=Europe/Bucharest

Embeddings WIRE-uit functional (PRD #15, decizie user): ensure_embeddings_corpus
construieste corpus din nomenclator, gated pe AUTOPASS_EMBEDDINGS_ENABLED (default
off). Marime model corectata ~50MB->~230MB (estimare PRD gresita).

Cleanup: hoist load_* din bucla bulk-fix; import re la top.
Regresie: 1256 passed, 1 deselected (live), 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:48:34 +00:00

547 lines
20 KiB
Python

"""Teste US-006 (PRD 5.15): prestatii multi-cod (lista) la editare/corectie.
AC-uri verificate:
- Handler-ele accepta LISTA de cod_prestatie (form.getlist) -> prestatii cu mai multe coduri.
- cod_op_service/denumire RAMAN pe item (invariant D7, E1 IRON RULE).
- Cod invalid -> respins cu mesaj; cod necunoscut NU ajunge la RAR (ORA-12899).
- Lista goala -> ramane needs_mapping.
- Dedup per-item: (op_service, cod) unic, NU cod unic (doua ops diferite cu acelasi cod ok).
- Recalcul idempotenta dupa editare.
- odometruInitial obligatoriu cand cod_prestatie contine R-ODO/I-ODO.
- REGRESIE E1 (IRON RULE): op_service supravietuieste /repune cu cod.
TDD: toate testele sunt scrise INAINTE de implementare (RED -> GREEN).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "prestatii.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _create_account_user(email: str, password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service", active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token nu gasit in login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _csrf(client) -> str:
resp = client.get("/?tab=coada")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token nu gasit in dashboard"
return m.group(1)
def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
k = key or f"k-{os.urandom(6).hex()}"
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(k, acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _row(sid: int):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
finally:
conn.close()
def _payload_json(sid: int) -> dict:
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone()
return json.loads(r["payload_json"])
finally:
conn.close()
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
"""Insereaza un cod in nomenclator_rar (fara operatii_mapping)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, denumire),
)
conn.commit()
finally:
conn.close()
def _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
"""Payload cu prestatii avand cod_op_service/denumire (needs_mapping state)."""
return {
"vin": vin,
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [
{"cod_op_service": op, "denumire": den}
for op, den in ops
],
}
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_mai_multe_coduri_acceptate(client):
"""US-006 AC1: LISTA de cod_prestatie -> prestatii cu N itemi, fiecare cu cod setat.
RED: form.get("cod_prestatie") intoarce doar primul cod; form.getlist necesar.
"""
acct = _create_account_user("multi.cod@test.com")
_login(client, "multi.cod@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("IG-1", "Inlocuire garnitura")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0MC001",
[("Op-A", "Schimb ulei motor"), ("Op-B", "Inlocuire garnitura chiulasa")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "IG-1"], # 2 coduri pentru 2 operatii
},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 2, f"asteptat 2 prestatii, got {len(prestatii)}: {prestatii}"
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 lipsa din prestatii: {prestatii}"
assert "IG-1" in coduri, f"IG-1 lipsa din prestatii: {prestatii}"
def test_cod_op_service_pastrat_dupa_corecteaza(client):
"""E1/D7: cod_op_service si denumire RAMAN pe item dupa /corecteaza cu cod direct.
RED: implementarea veche injecta in prestatii[0] fara sa afecteze op_service
(intr-adevar in /corecteaza nu se facea pop), dar testul confirma explicit invariantul.
"""
acct = _create_account_user("op.pastrat@test.com")
_login(client, "op.pastrat@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0OP001",
[("Schimb ulei", "Schimb ulei motor 5W30")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 1
item = prestatii[0]
assert item.get("cod_prestatie") == "OE-1", f"cod_prestatie lipsa: {item}"
assert item.get("cod_op_service") == "Schimb ulei", f"cod_op_service pierdut: {item}"
assert item.get("denumire") == "Schimb ulei motor 5W30", f"denumire pierduta: {item}"
def test_cod_invalid_respins(client):
"""US-006 AC3: cod necunoscut in nomenclator -> respins cu mesaj, status neschimbat.
RED: validarea fata de nomenclator nu e aplicata per-cod la multi-select.
"""
acct = _create_account_user("cod.invalid@test.com")
_login(client, "cod.invalid@test.com")
# NU seed-uim "XX-99" -> cod necunoscut
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0CI001",
[("Op-Test", "Operatie test")],
))
old_status = _row(sid)["status"]
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "XX-99"},
)
assert resp.status_code == 200
# Cod invalid -> mesaj de eroare vizibil
assert "XX-99" in resp.text or "necunoscut" in resp.text.lower(), (
f"Mesaj de eroare lipsa pentru cod invalid; text={resp.text[:500]}"
)
# Status neschimbat
assert _row(sid)["status"] == old_status, (
f"Status s-a schimbat desi codul e invalid: {_row(sid)['status']}"
)
def test_lista_goala_needs_mapping(client):
"""US-006 AC4: nicio cod_prestatie trimis -> submission ramane needs_mapping.
RED: cu multi-select, lista goala nu injecteaza nimic; resolve_prestatii
gaseste inca operatii nemapate -> trebuie sa ramana needs_mapping.
"""
acct = _create_account_user("goala.nemap@test.com")
_login(client, "goala.nemap@test.com")
# NU seed-uim nicio mapare -> operatia ramane nemapata
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0GN001",
[("Op-Nemap", "Operatie nemapata")],
))
csrf = _csrf(client)
# Trimit form FARA cod_prestatie (lista goala)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "needs_mapping", (
f"Status trebuia sa ramana needs_mapping, got {_row(sid)['status']}"
)
def test_idempotency_recalculat(client):
"""US-006 AC6: dupa setarea de coduri noi, cheia de idempotenta e recalculata.
RED: single-cod injecta in prestatii[0] si recalcula cheia; cu multi-cod
acelasi mecanism se aplica tuturor itemilor.
"""
acct = _create_account_user("ido.recalc@test.com")
_login(client, "ido.recalc@test.com")
_seed_cod("OE-1")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0ND001",
[("Op-Ido", "Operatie ido")],
))
old_key = _row(sid)["idempotency_key"]
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "queued"
new_key = _row(sid)["idempotency_key"]
assert new_key != old_key, (
f"Cheia de idempotenta NU s-a schimbat dupa setarea codului: {new_key}"
)
def test_odometru_initial_conditionat_R_ODO(client):
"""US-006 AC7: cod_prestatie=R-ODO fara odometruInitial -> validate_prezentare
intoarce eroare -> submission ramane needs_data (NU queued).
RED: validarea R-ODO e deja in validate_prezentare; testul confirma ca
multi-cod nu bypass-eaza aceasta regula.
"""
acct = _create_account_user("odo.rodo@test.com")
_login(client, "odo.rodo@test.com")
_seed_cod("R-ODO", "Revizie odometru")
# Payload: needs_mapping (op fara cod), FARA odometru_initial
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0RO001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
# odometru_initial ABSENT
"prestatii": [{"cod_op_service": "Revizie", "denumire": "Revizie odometru"}],
})
csrf = _csrf(client)
# Trimit R-ODO ca cod (valid in nomenclator), dar fara odometru_initial
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "cod_prestatie": "R-ODO"},
)
assert resp.status_code == 200
status = _row(sid)["status"]
# R-ODO fara odometruInitial -> validare esuata -> needs_data (nu queued)
assert status in ("needs_data", "needs_mapping"), (
f"Status neasteptat: {status}; trebuia needs_data/needs_mapping (R-ODO fara odo initial)"
)
assert status != "queued", (
"R-ODO fara odometruInitial NU trebuie sa treaca in queued!"
)
def test_dedup_per_item_nu_dupa_cod(client):
"""US-006 AC5 (E4): doua operatii DIFERITE cu ACELASI cod RAR ambele supravietuiesc.
Dedup = (op_service, cod) identice, NU cod singur. Doua ops distincte pot
mapa legitim la acelasi cod RAR fara sa fie sterse de dedup.
RED: dedupare naiva dupa cod ar sterge a doua operatie (op-B cu acelasi OE-1).
"""
acct = _create_account_user("dedup.ops@test.com")
_login(client, "dedup.ops@test.com")
_seed_cod("OE-1", "Schimb ulei")
# Doua operatii distincte, ambele vor primi OE-1
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0DD001",
[("Op-A", "Prima operatie"), ("Op-B", "A doua operatie")],
))
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "OE-1"], # acelasi cod pentru ambele ops
},
)
assert resp.status_code == 200
prestatii = _payload_json(sid)["prestatii"]
# Ambele TREBUIE sa supravietuiasca: (Op-A, OE-1) != (Op-B, OE-1)
assert len(prestatii) == 2, (
f"Dedup a sters o operatie distincta! prestatii={prestatii} "
"(doua ops cu acelasi cod trebuie pastrate)"
)
ops = [p.get("cod_op_service") for p in prestatii]
assert "Op-A" in ops and "Op-B" in ops, f"ops_service pierdute: {ops}"
# --------------------------------------------------------------------------- #
# Test de regresie E1 (IRON RULE): op_service supravietuieste /repune cu cod #
# --------------------------------------------------------------------------- #
def test_op_service_supravietuieste_repune_cu_cod(client):
"""E1 IRON RULE: dupa /repune cu cod_prestatie, cod_op_service/denumire RAMAN pe item.
RED: routes.py:1371 face `p0.pop("cod_op_service", None)` — sterge operatia
cand se seteaza un cod direct prin /repune. US-006 ELIMINA acel pop.
Aceasta regresie e CRITICA: sterge contextul op->cod necesar pentru US-009
(salvare mapare din chip) si rupe invariantul D7.
"""
acct = _create_account_user("e1.repune@test.com")
_login(client, "e1.repune@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
# Starea error: payload cu op_service (operatia venita de la import/API)
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0E1001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{
"cod_op_service": "Schimb ulei",
"denumire": "Schimb ulei motor 5W30",
# fara cod_prestatie initial
}],
})
csrf = _csrf(client)
# /repune cu cod direct
resp = client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 1
item = prestatii[0]
# IRON RULE E1: op_service si denumire TREBUIE sa fie prezente
assert item.get("cod_op_service") == "Schimb ulei", (
f"E1 VIOLATED: cod_op_service a fost sters de /repune! item={item}"
)
assert item.get("denumire") == "Schimb ulei motor 5W30", (
f"E1 VIOLATED: denumire a fost stearsa de /repune! item={item}"
)
# Codul trebuie setat
assert item.get("cod_prestatie") == "OE-1", (
f"cod_prestatie nu a fost setat corect: item={item}"
)
def test_repune_nu_trunchiaza_prestatii_multiple(client):
"""Bug fix (code-review 5.15): /repune NU pierde prestatii[1:].
Formularul /repune trimite UN SINGUR select cod_prestatie. Implementarea veche
itera `enumerate(codes)` -> pastra doar len(codes) itemi, deci un rand error cu
2+ prestatii pierdea toate prestatiile dupa prima -> declaratie INCOMPLETA la RAR
(FINALIZATA ireversibil). Fix: iteram peste `existing`, aplicam codes pozitional,
pastram toate prestatiile.
RED inainte de fix: len(prestatii) == 1 (a doua prestatie pierduta).
"""
acct = _create_account_user("repune.multi@test.com")
_login(client, "repune.multi@test.com")
_seed_cod("AAA", "Prestatie A")
_seed_cod("BBB", "Prestatie B")
_seed_cod("CCC", "Prestatie C")
# Rand error cu DOUA prestatii (ambele cu cod valid).
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0RM001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [
{"cod_prestatie": "AAA"},
{"cod_prestatie": "BBB"},
],
})
csrf = _csrf(client)
# /repune cu UN SINGUR cod nou (schimba prima prestatie).
resp = client.post(
f"/trimitere/{sid}/repune",
data={"csrf_token": csrf, "cod_prestatie": "CCC"},
)
assert resp.status_code == 200, resp.text[:500]
r = _row(sid)
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
assert len(prestatii) == 2, (
f"AMBELE prestatii trebuie pastrate de /repune, nu doar prima! got={prestatii}"
)
coduri = [p.get("cod_prestatie") for p in prestatii]
assert coduri == ["CCC", "BBB"], (
f"Codul nou se aplica POZITIONAL primei prestatii, a doua ramane intacta: {coduri}"
)
def test_corectie_eroare_validare_pastreaza_picker(client):
"""Bug fix (code-review 5.15): re-render-ul de eroare validare pastreaza optiunile pickerului.
post_corectie_trimitere re-randa _trimitere_detaliu pe ramura erori-validare FARA
`conn`/`account_id` -> `nomenclator_rar=[]` -> picker-ul chips randa ZERO optiuni ->
userul nu mai poate alege cod RAR fara sa inchida+redeschida modalul. Fix: pasam
`conn`+`account_id` la _detaliu_ctx pe TOATE ramurile de re-render.
RED inainte de fix: codul de picker "PK-1" lipseste din re-render.
"""
acct = _create_account_user("corectie.picker@test.com")
_login(client, "corectie.picker@test.com")
_seed_cod("ZZ-9", "Operatie existenta") # codul curent al randului (valid -> fara unmapped)
_seed_cod("PK-1", "Optiune picker") # cod doar in nomenclator (detector de picker)
# needs_data editabil, prestatie cu cod direct valid (resolve OK, fara unmapped).
sid = _insert(acct, status="needs_data", payload={
"vin": "WVWZZZ1JZXW0PK001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "ZZ-9"}],
})
csrf = _csrf(client)
# Corectie cu VIN invalid -> validare esueaza -> ramura de re-render 1432.
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={"csrf_token": csrf, "vin": "BAD"},
)
assert resp.status_code == 200
assert _row(sid)["status"] == "needs_data"
# Picker-ul trebuie sa contina optiunile din nomenclator (conn/account_id pasate).
assert "PK-1" in resp.text, (
"Picker-ul chips e GOL dupa eroare de validare — _detaliu_ctx fara conn/account_id"
)
def test_repune_select_afiseaza_denumirea(client):
"""Bug fix (code-review 5.15): selectul /repune afiseaza denumirea operatiei.
Template-ul folosea cheia gresita `item.nome_prestatie` (typo) -> optiunile
apareau ca "AAA — " fara denumire. Cheia corecta e `nume_prestatie`.
"""
acct = _create_account_user("repune.denumire@test.com")
_login(client, "repune.denumire@test.com")
_seed_cod("AAA", "Schimb ulei motor")
sid = _insert(acct, status="error", payload={
"vin": "WVWZZZ1JZXW0RD001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "AAA"}],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Optiunea trebuie sa afiseze denumirea, nu doar codul gol.
assert "Schimb ulei motor" in html, (
"Selectul /repune nu afiseaza denumirea operatiei (typo nome_prestatie)"
)
assert "AAA — Schimb ulei motor" in html, (
f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}"
)