Files
rar-autopass/tests/test_web_editare_rand.py
Claude Agent 56ebe1f7cb feat(editor): editor prestatii unificat in modal - un select, un rand de chips (PRD 5.23)
Inlocuieste cele 3 controale de asociere cod RAR cu un singur rand de chips
+ un singur select care adauga instant la change. Stare = un hidden chips_state
JSON versionat; post_form_chips redus la 2 actiuni (add/remove). Optgroup
Sugestii (fuzzy/k-NN) + optiune "Nu se declara la RAR" in select, cu tinta
implicita evidentiata si placeholder care o numeste. exclus persistat prin
payload_json (treapta noua de precedenta in resolve_prestatii, round-trip complet).

Siguranta: itemii exclusi sunt scosi din payload la momentul trimiterii
(worker split_prestatii_excluse inainte de build_rar_payload + filtru defensiv),
ca sa nu ajunga NICIODATA la RAR ca codPrestatie:null.

Suita: 1680 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:45:34 +00:00

422 lines
18 KiB
Python

"""US-004: persistarea `exclus` prin salvare (import + Trimiteri), round-trip complet.
Acopera:
- resolve_prestatii: item-exclus EXPLICIT supravietuieste re-rezolvarii (treapta noua
de precedenta) FARA sa strice restul precedentei (IRON RULE, regresiune-cheie).
- Import (web_editeaza_rand / apply_row_override): `exclus` persistat in override_json,
chip Nedeclarat reapare la redeschiderea modalului, x il readuce la warning.
- Rand cu toate operatiile excluse -> stare preview 'excluded' ("Nedeclarat").
- Trimiteri (post_corectie_trimitere): acelasi round-trip pe chips_state; toate
excluse -> needs_data cu motiv explicit.
- Sentinelul __NEDECLARAT__ NU ajunge in payload daca apare direct ca cod_prestatie
in starea hidden (nu doar ales din select), pe AMBELE cai.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
# --------------------------------------------------------------------------- #
# Test unitar (fara client): precedenta resolve_prestatii #
# --------------------------------------------------------------------------- #
def test_exclus_supravietuieste_resolve_chiar_cu_mapare_pe_cont():
"""IRON RULE: item cu `exclus=1` explicit pe item RAMANE exclus dupa resolve_prestatii,
chiar daca operatia are (a) o mapare exacta pe cont SAU (b) un cod_prestatie deja
valid pe rand. Regresiune-cheie: restul precedentei (cod valid > exclus de cont >
mapare exacta > reguli text > nemapat) trebuie sa ramana IDENTICA pentru itemii
FARA `exclus` explicit — altfel stricam maparile existente.
"""
from app.mapping import resolve_prestatii
valid_codes = {"OE-1", "IG-1", "FRN1"}
mapping = {"Schimb-Ulei": "OE-1"} # mapare exacta pe cont pentru Schimb-Ulei
excluded_ops_cont = {"Filtru-Aer"} # regula de excludere PE CONT (alt mecanism)
prestatii = [
# (a) exclus explicit + mapare exacta pe cont exista -> exclus CASTIGA
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor", "exclus": 1},
# (b) exclus explicit + cod_prestatie deja valid pe rand -> exclus CASTIGA
{"cod_op_service": "Frana", "denumire": "Reparatie frana", "cod_prestatie": "FRN1", "exclus": 1},
# regresiune: FARA exclus explicit, mapare exacta trebuie sa functioneze ca inainte
{"cod_op_service": "Schimb-Ulei", "denumire": "Schimb ulei motor 2"},
# regresiune: FARA exclus explicit, regula de excludere PE CONT trebuie sa functioneze
{"cod_op_service": "Filtru-Aer", "denumire": "Filtru aer"},
# regresiune: cod deja valid pe rand, FARA exclus -> pastrat ca atare (cod_valid)
{"cod_op_service": "", "denumire": "", "cod_prestatie": "IG-1"},
# regresiune: nemapat ramane nemapat
{"cod_op_service": "Necunoscut-Op", "denumire": "Operatie necunoscuta"},
]
resolved, unmapped = resolve_prestatii(
prestatii, mapping, valid_codes=valid_codes, text_rules=None, excluded_ops=excluded_ops_cont,
)
item_a = resolved[0]
assert item_a.get("exclus") is True, f"exclus explicit trebuia sa supravietuiasca: {item_a}"
assert not item_a.get("cod_prestatie"), f"item exclus explicit nu trebuie sa aiba cod_prestatie: {item_a}"
item_b = resolved[1]
assert item_b.get("exclus") is True, f"exclus explicit trebuia sa domine cod_prestatie valid: {item_b}"
assert not item_b.get("cod_prestatie"), f"cod_prestatie valid trebuia sters de exclus explicit: {item_b}"
# Regresiune: mapare exacta pe cont NESCHIMBATA pentru itemul fara exclus
item_c = resolved[2]
assert item_c.get("cod_prestatie") == "OE-1", f"mapare exacta pe cont stricata: {item_c}"
assert not item_c.get("exclus"), f"item fara exclus explicit nu trebuie marcat exclus: {item_c}"
# Regresiune: regula de excludere PE CONT (mecanism vechi) NESCHIMBATA
item_d = resolved[3]
assert item_d.get("exclus") is True, f"regula de excludere pe cont stricata: {item_d}"
assert not item_d.get("cod_prestatie")
# Regresiune: cod deja valid pastrat ca atare
item_e = resolved[4]
assert item_e.get("cod_prestatie") == "IG-1", f"cod valid pastrat stricat: {item_e}"
# Regresiune: nemapat ramane nemapat
assert len(unmapped) == 1
assert unmapped[0]["cod_op_service"] == "Necunoscut-Op"
def test_precedenta_neschimbata_pentru_itemi_fara_exclus():
"""Regresiune suplimentara: fara `exclus` explicit pe niciun item, comportamentul
lui resolve_prestatii e IDENTIC cu cel dinaintea treptei noi (cod valid > exclus
de cont > mapare exacta > reguli text > nemapat).
"""
from app.mapping import resolve_prestatii
valid_codes = {"OE-1"}
mapping = {"Op-Mapata": "OE-1"}
text_rules = [{"pattern": "frana", "cod_prestatie": "OE-1", "auto_send": 1, "priority": 1}]
excluded_ops = {"Op-Exclusa"}
prestatii = [
{"cod_op_service": "Op-Mapata", "denumire": "Operatie mapata"},
{"cod_op_service": "Op-Exclusa", "denumire": "Operatie exclusa"},
{"cod_op_service": "Op-Text", "denumire": "Reparatie frana fata"},
{"cod_op_service": "Op-Nemapat", "denumire": "Chestie noua"},
]
resolved, unmapped = resolve_prestatii(prestatii, mapping, valid_codes, text_rules, excluded_ops)
assert resolved[0]["cod_prestatie"] == "OE-1"
assert not resolved[0].get("exclus")
assert resolved[1]["exclus"] is True
assert not resolved[1].get("cod_prestatie")
assert resolved[2]["cod_prestatie"] == "OE-1" # via regula text
assert resolved[2].get("cod_sursa")
assert len(unmapped) == 1
assert unmapped[0]["cod_op_service"] == "Op-Nemapat"
# --------------------------------------------------------------------------- #
# Fixtures + helpere HTTP (import + Trimiteri, fara auth — cont 1 implicit) #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "editare_rand.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()
def _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
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 _csv_bytes(rows: list[dict]) -> bytes:
import csv as _csv
import io
buf = io.StringIO()
writer = _csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _upload_and_preview(client: TestClient, rows: list[dict]) -> int:
import io as _io
csv_data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", _io.BytesIO(csv_data), "text/csv")},
data={},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "YYYY-MM-DD",
})
assert r2.status_code == 200, r2.text
return iid
def _chips_state_din_html(html: str) -> dict:
m = re.search(r'name="chips_state"\s+value=\'([^\']*)\'', html)
assert m, f"hidden chips_state lipsa din HTML: {html[:400]}"
return json.loads(m.group(1).replace("&#34;", '"'))
# --- Trimiteri: helpere submissions direct in DB (cont 1, fara auth) --------- #
def _insert_submission(*, status: str, payload: dict) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", 1, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _submission_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 _submission_payload(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 _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
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],
}
# --------------------------------------------------------------------------- #
# Import: salvare + round-trip (web_editeaza_rand / apply_row_override) #
# --------------------------------------------------------------------------- #
_SAMPLE_ROW_1OP = [{
"VIN": "WVWZZZ1KZAW000999",
"Nr": "B009TST",
"Data": "2026-06-10",
"KM": "123456",
"Operatie": "Op-Nemapata",
}]
def test_salvare_pastreaza_exclus_in_override(client):
"""Salvarea unui chip Nedeclarat in modalul de editare (import) persista `exclus=1`
in override_json — la reincarcarea preview-ului randul ramane 'Nedeclarat'."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text
trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" in trigger_settle, f"Salvarea trebuia sa reuseasca: {r.text[:400]}"
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r2.status_code == 200
payload = _chips_state_din_html(r2.text)
item = payload["items"][0]
assert item.get("exclus") == 1, f"exclus nu a fost persistat: {payload}"
assert not item.get("cod"), f"item exclus nu trebuie sa aiba cod: {payload}"
def test_redeschidere_modal_arata_chip_nedeclarat_si_x_readuce_warning(client):
"""Round-trip complet: dupa salvare, redeschiderea modalului arata chipul
'Nedeclarat' (nu warning); actiunea x (/form-chips remove) il readuce la warning."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r2.status_code == 200
html = r2.text
assert "Nedeclarat" in html, f"Chipul Nedeclarat trebuia sa reapara la redeschidere: {html[:600]}"
assert "chip-nedeclarat" in html
# x pe chip Nedeclarat -> /form-chips remove index 0 -> revine la warning.
payload = _chips_state_din_html(html)
r3 = client.post("/form-chips", data={
"chips_state": json.dumps(payload),
"chips_action": "remove",
"chips_remove_index": "0",
})
assert r3.status_code == 200, r3.text
assert "Nedeclarat" not in r3.text, "Dupa x, chipul Nedeclarat nu mai trebuie sa apara"
payload3 = _chips_state_din_html(r3.text)
item3 = payload3["items"][0]
assert not item3.get("exclus"), f"x trebuia sa readuca operatia la warning (exclus=0): {payload3}"
assert not item3.get("cod"), f"operatia trebuia sa ramana fara cod dupa x: {payload3}"
def test_rand_toate_operatiile_excluse_devine_excluded(client):
"""Rand cu unica operatie exclusa -> stare preview 'excluded' ('Nedeclarat')."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 1},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text
trigger = json.loads(r.headers.get("HX-Trigger", "{}"))
stare = trigger.get("randSalvat", {}).get("stare", "")
assert "Nedeclarat" in stare, f"randSalvat.stare trebuia sa arate Nedeclarat, got: {stare!r}"
# --------------------------------------------------------------------------- #
# Trimiteri: salvare + round-trip (post_corectie_trimitere) #
# --------------------------------------------------------------------------- #
def test_salvare_trimitere_cu_exclus(client):
"""/trimitere/{id}/corecteaza cu un item mapat + un item exclus -> queued,
payload_json pastreaza AMBELE (itemul exclus adnotat exclus=1, fara cod_prestatie)."""
_seed_cod("OE-1", "Schimb ulei")
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0EX001",
[("SchimbUlei", "Schimb ulei motor"), ("Filtru-Aer", "Filtru aer")],
))
chips_state = json.dumps({"v": 1, "items": [
{"cod": "OE-1", "op": "SchimbUlei", "den": "Schimb ulei motor", "exclus": 0},
{"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text[:500]
row = _submission_row(sid)
assert row["status"] == "queued", f"status asteptat queued, got {row['status']}: {r.text[:500]}"
prestatii = _submission_payload(sid)["prestatii"]
assert len(prestatii) == 2, f"ambele itemi trebuiau pastrate in payload: {prestatii}"
by_op = {p.get("cod_op_service"): p for p in prestatii}
assert by_op["SchimbUlei"].get("cod_prestatie") == "OE-1"
assert by_op["Filtru-Aer"].get("exclus") is True, f"itemul exclus trebuia adnotat: {prestatii}"
assert not by_op["Filtru-Aer"].get("cod_prestatie"), f"itemul exclus nu trebuie sa aiba cod: {prestatii}"
def test_trimitere_toate_excluse_devine_needs_data(client):
"""Toate operatiile excluse -> needs_data cu motiv explicit (nu se trimite la RAR)."""
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0EX002",
[("Filtru-Aer", "Filtru aer")],
))
chips_state = json.dumps({"v": 1, "items": [
{"cod": "", "op": "Filtru-Aer", "den": "Filtru aer", "exclus": 1},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200, r.text[:500]
row = _submission_row(sid)
assert row["status"] == "needs_data", f"status asteptat needs_data, got {row['status']}"
assert "excluse" in (row["rar_error"] or "").lower()
prestatii = _submission_payload(sid)["prestatii"]
assert prestatii[0].get("exclus") is True, "itemul exclus trebuie pastrat in payload pt afisare motiv"
# --------------------------------------------------------------------------- #
# Sentinel __NEDECLARAT__ respins explicit daca apare ca cod_prestatie #
# --------------------------------------------------------------------------- #
def test_sentinel_nedeclarat_nu_ajunge_in_payload_import(client):
"""Daca cineva fabrica direct chips_state cu cod=__NEDECLARAT__ (nu prin selectul
care seteaza exclus), salvarea trebuie respinsa — sentinelul nu e in nomenclator."""
iid = _upload_and_preview(client, _SAMPLE_ROW_1OP)
chips_state = json.dumps({"v": 1, "items": [
{"cod": "__NEDECLARAT__", "op": "Op-Nemapata", "den": "Op-Nemapata", "exclus": 0},
]})
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"chips_state": chips_state})
assert r.status_code == 200
assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}"
trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" not in trigger_settle, "Salvarea NU trebuia sa reuseasca cu sentinelul in payload"
# Confirma ca sentinelul NU a ajuns in override (redeschiderea nu-l arata ca si cod).
r2 = client.get(f"/_import/{iid}/rand/0/editare-modal")
payload2 = _chips_state_din_html(r2.text)
coduri = [it.get("cod") for it in payload2["items"]]
assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in starea persistata: {payload2}"
def test_sentinel_nedeclarat_nu_ajunge_in_payload_trimitere(client):
"""Aceeasi respingere pe calea Trimiteri (post_corectie_trimitere)."""
sid = _insert_submission(status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0SN001",
[("Op-Test", "Operatie test")],
))
old_status = _submission_row(sid)["status"]
chips_state = json.dumps({"v": 1, "items": [
{"cod": "__NEDECLARAT__", "op": "Op-Test", "den": "Operatie test", "exclus": 0},
]})
r = client.post(f"/trimitere/{sid}/corecteaza", data={"chips_state": chips_state})
assert r.status_code == 200
assert "necunoscut" in r.text.lower(), f"Sentinelul trebuia respins ca cod necunoscut: {r.text[:400]}"
row = _submission_row(sid)
assert row["status"] == old_status, f"Statusul nu trebuia sa se schimbe: {row['status']}"
prestatii = _submission_payload(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "__NEDECLARAT__" not in coduri, f"Sentinelul a ajuns in payload: {prestatii}"