feat(5.8): reguli mapare pe text (substring/cont) + UX tabel trimiteri (detaliu inline, fara scroll, cod RAR)

Reguli text per cont (operation_text_rules), resolve_prestatii cu param aditiv
text_rules + precedenta stricta, threadat pe toate cele 6 callsite-uri + valid_codes
+ seam classify_prezentare. UI Mapari: sectiune reguli + preview pre-salvare + overlap
+ telemetrie text_rule_hit. UX tabel: cod_rar sub operatie, pill eticheta scurta, fara
scroll orizontal (scopat .tabel-trimiteri + carduri <768px), detaliu inline expandabil
(a11y + pauza poll). code-review: reparat regula auto_send=0 care trimitea automat la RAR
in loc sa tina randul pentru review. 814 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-24 12:47:37 +00:00
parent c80c79462c
commit 51dc504f1d
28 changed files with 3023 additions and 61 deletions

63
tests/test_labels.py Normal file
View File

@@ -0,0 +1,63 @@
"""
Teste pentru eticheta_scurta (US-006, PRD 5.8).
RED intai: scrise inainte de implementarea functiei.
Fisiere atinse: app/web/labels.py, tests/test_labels.py.
"""
import pytest
from app.web.labels import eticheta_scurta, eticheta_stare
# ---------------------------------------------------------------------------
# test_eticheta_scurta_pentru_fiecare_stare
# ---------------------------------------------------------------------------
def test_eticheta_scurta_pentru_fiecare_stare():
"""Fiecare stare cunoscuta intoarce eticheta scurta corecta (pill)."""
cazuri = {
"queued": "In coada",
"sending": "Se trimite",
"sent": "Finalizat",
"needs_mapping": "De mapat",
"needs_data": "Date lipsa",
"error": "Eroare",
}
for status, eticheta_asteptata in cazuri.items():
rezultat = eticheta_scurta(status)
assert rezultat == eticheta_asteptata, (
f"Status {status!r}: asteptam {eticheta_asteptata!r}, got {rezultat!r}"
)
def test_eticheta_scurta_stare_necunoscuta_ridica_keyerror():
"""Stare neacoperita ridica KeyError (ca sa prinda stari noi adaugate in schema)."""
with pytest.raises(KeyError):
eticheta_scurta("stare_inexistenta")
# ---------------------------------------------------------------------------
# test_eticheta_lunga_ramane_pentru_subtext
# ---------------------------------------------------------------------------
def test_eticheta_lunga_ramane_pentru_subtext():
"""eticheta_stare inca intoarce textele lungi neschimbate (compat cu template-uri)."""
# Verificam fragmentele de text lung care existau inainte de US-006
cazuri_lungi = {
"queued": "In asteptare",
"sending": "Se trimite acum",
"sent": "Declarate la RAR",
"needs_mapping": "Lipseste codul",
"needs_data": "Date incomplete",
"error": "Eroare la trimitere",
}
for status, fragment in cazuri_lungi.items():
text, subtext, css_class = eticheta_stare(status)
assert fragment.lower() in text.lower(), (
f"eticheta_stare({status!r}) text lung modificat — asteptam {fragment!r} in {text!r}"
)
# Verifica ca tuple-ul are exact 3 elemente (invariant arhitectura C1)
assert isinstance(css_class, str) and css_class, (
f"eticheta_stare({status!r}) trebuie sa aiba css_class non-vida la pozitia 3"
)

View File

@@ -108,6 +108,78 @@ def test_resolve_fara_valid_codes_e_backcompat():
assert unmapped == []
# --------------------------------------------------------------------------- #
# US-002: reguli text (substring) dupa maparea exacta #
# --------------------------------------------------------------------------- #
def test_regula_text_contains_rezolva():
"""O operatie nemapata al carei text CONTINE pattern-ul primeste codul regulii."""
text_rules = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP1", "denumire": "Verificare faruri"}],
{},
valid_codes={"OE-2"},
text_rules=text_rules,
)
assert resolved[0]["cod_prestatie"] == "OE-2"
assert unmapped == []
def test_mapare_exacta_bate_regula_text():
"""Maparea exacta cod_op_service->cod are precedenta peste regula text."""
text_rules = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP1", "denumire": "Verificare faruri"}],
{"OP1": "OE-1"},
valid_codes={"OE-1", "OE-2"},
text_rules=text_rules,
)
assert resolved[0]["cod_prestatie"] == "OE-1" # maparea exacta castiga
assert unmapped == []
def test_regula_text_insensibila_diacritice_caz():
"""Match-ul e insensibil la diacritice si majuscule (ambele parti normalizate)."""
text_rules = [{"pattern": "Verificări", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP1", "denumire": "VERIFICARI complete auto"}],
{},
valid_codes={"OE-2"},
text_rules=text_rules,
)
assert resolved[0]["cod_prestatie"] == "OE-2"
assert unmapped == []
def test_regula_text_cod_invalid_in_nomenclator_ramane_nemapat():
"""Regula da match dar codul ei nu e in nomenclator -> operatia ramane nemapata."""
text_rules = [{"pattern": "verificare", "cod_prestatie": "ZZZ", "auto_send": 0, "priority": 0}]
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP1", "denumire": "Verificare faruri"}],
{},
valid_codes={"OE-2"},
text_rules=text_rules,
)
assert resolved[0]["cod_prestatie"] is None
assert unmapped == [{"cod_op_service": "OP1", "denumire": "Verificare faruri"}]
def test_prima_regula_dupa_priority_castiga():
"""La match multiplu castiga prima regula in ordinea listei (priority, id)."""
text_rules = [
{"pattern": "verificare faruri", "cod_prestatie": "OE-3", "auto_send": 0, "priority": 0},
{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 1},
]
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "OP1", "denumire": "Verificare faruri"}],
{},
valid_codes={"OE-2", "OE-3"},
text_rules=text_rules,
)
assert resolved[0]["cod_prestatie"] == "OE-3" # prima din lista
assert unmapped == []
# --------------------------------------------------------------------------- #
# Flux complet (API) #
# --------------------------------------------------------------------------- #

View File

@@ -0,0 +1,53 @@
"""Teste US-011 (PRD 5.8) — helper pur `text_rules_overlap`.
Avertisment neblocant cand o regula text noua se suprapune (substring, oricare
directie) cu una existenta. Helper determinist, fara DB.
"""
from __future__ import annotations
from app.mapping import text_rules_overlap
def test_overlap_substring_ambele_directii():
"""Overlap = pattern nou substring al unei reguli existente SAU invers."""
existing = [
{"pattern": "verificare faruri", "cod_prestatie": "OE-3"},
{"pattern": "schimb ulei", "cod_prestatie": "OE-1"},
]
# nou „verificare" e substring al „verificare faruri" -> overlap
hits = text_rules_overlap("verificare", existing)
assert len(hits) == 1
assert hits[0]["pattern"] == "verificare faruri"
# invers: nou „verificare faruri spate" CONTINE „verificare faruri" -> overlap
hits2 = text_rules_overlap("verificare faruri spate", existing)
assert len(hits2) == 1
assert hits2[0]["pattern"] == "verificare faruri"
def test_fara_overlap():
"""Pattern fara nicio relatie de substring -> lista goala.
Pattern IDENTIC (dupa normalizare) cu unul existent NU e overlap: e un upsert
(update al codului), nu o suprapunere care merita avertisment.
"""
existing = [
{"pattern": "verificare", "cod_prestatie": "OE-2"},
]
assert text_rules_overlap("schimb ulei", existing) == []
# identic dupa normalizare -> update, nu overlap
assert text_rules_overlap("VERIFICARE", existing) == []
# fara reguli existente
assert text_rules_overlap("verificare", []) == []
def test_overlap_normalizat_diacritice():
"""Normalizarea (diacritice + caz) se aplica pe ambele parti la match."""
existing = [
{"pattern": "verificare completa", "cod_prestatie": "OE-2"},
]
# „Verificăre" -> „VERIFICARE", substring al „VERIFICARE COMPLETA" -> overlap
hits = text_rules_overlap("Verificăre", existing)
assert len(hits) == 1
assert hits[0]["pattern"] == "verificare completa"

View File

@@ -0,0 +1,147 @@
"""US-001: Schema + persistenta reguli text de mapare (operation_text_rules).
Teste RED-first: verifica tabela, functiile de persistenta si unicitatea per cont.
"""
from __future__ import annotations
import os
import tempfile
import pytest
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "text_rules.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import init_db
init_db()
yield monkeypatch
get_settings.cache_clear()
@pytest.fixture()
def conn(env):
from app.db import get_connection
c = get_connection()
yield c
c.close()
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_save_text_rule_persista(conn):
"""save_text_rule insereaza un rand; load_text_rules il returneaza."""
from app.mapping import save_text_rule, load_text_rules
save_text_rule(conn, account_id=1, pattern="verificare", cod_prestatie="OE-2", auto_send=False)
conn.commit()
rules = load_text_rules(conn, account_id=1)
assert len(rules) == 1
r = rules[0]
assert r["pattern"] == "verificare"
assert r["cod_prestatie"] == "OE-2"
assert r["auto_send"] == 0
assert r["priority"] == 0
def test_load_text_rules_per_cont(conn):
"""load_text_rules returneaza DOAR regulile contului dat, nu ale altor conturi."""
from app.mapping import save_text_rule, load_text_rules
# Creeaza un cont suplimentar (id=2)
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'cont2')")
conn.commit()
save_text_rule(conn, account_id=1, pattern="revizie", cod_prestatie="OE-1", auto_send=False)
save_text_rule(conn, account_id=2, pattern="vopsitorie", cod_prestatie="OE-3", auto_send=False)
conn.commit()
rules1 = load_text_rules(conn, account_id=1)
rules2 = load_text_rules(conn, account_id=2)
assert len(rules1) == 1
assert rules1[0]["pattern"] == "revizie"
assert len(rules2) == 1
assert rules2[0]["pattern"] == "vopsitorie"
def test_delete_text_rule(conn):
"""delete_text_rule sterge regula specificata; load_text_rules returneaza lista vida."""
from app.mapping import save_text_rule, load_text_rules, delete_text_rule
save_text_rule(conn, account_id=1, pattern="schimb ulei", cod_prestatie="OE-2", auto_send=True)
conn.commit()
assert len(load_text_rules(conn, account_id=1)) == 1
delete_text_rule(conn, account_id=1, pattern="schimb ulei")
conn.commit()
assert load_text_rules(conn, account_id=1) == []
def test_unic_per_cont_pattern(conn):
"""save_text_rule face upsert: al doilea apel cu acelasi (account_id, pattern)
actualizeaza cod_prestatie si auto_send, nu creeaza un rand nou."""
from app.mapping import save_text_rule, load_text_rules
save_text_rule(conn, account_id=1, pattern="inspectie", cod_prestatie="OE-1", auto_send=False)
conn.commit()
# Al doilea apel = upsert: schimba codul si auto_send
save_text_rule(conn, account_id=1, pattern="inspectie", cod_prestatie="OE-2", auto_send=True)
conn.commit()
rules = load_text_rules(conn, account_id=1)
assert len(rules) == 1 # nu s-a dublat
assert rules[0]["cod_prestatie"] == "OE-2"
assert rules[0]["auto_send"] == 1
def test_load_text_rules_ordine(conn):
"""load_text_rules returneaza regulile ordonate priority ASC, id ASC."""
from app.mapping import save_text_rule, load_text_rules
# Inseram direct cu priority diferit ca sa testam ordinea
conn.execute(
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) "
"VALUES (1, 'gamma', 'OE-3', 0, 10)"
)
conn.execute(
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) "
"VALUES (1, 'alfa', 'OE-1', 0, 0)"
)
conn.execute(
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send, priority) "
"VALUES (1, 'beta', 'OE-2', 0, 0)"
)
conn.commit()
rules = load_text_rules(conn, account_id=1)
# priority 0 inainte de priority 10; la egalitate de priority: id ASC (alfa < beta < gamma)
assert rules[0]["pattern"] == "alfa"
assert rules[1]["pattern"] == "beta"
assert rules[2]["pattern"] == "gamma"
def test_account_or_default_none_equals_1(conn):
"""load_text_rules cu account_id=None aplica account_or_default -> returneaza regulile contului 1."""
from app.mapping import save_text_rule, load_text_rules
save_text_rule(conn, account_id=None, pattern="reparatie", cod_prestatie="OE-1", auto_send=False)
conn.commit()
rules = load_text_rules(conn, account_id=None)
assert len(rules) == 1
assert rules[0]["pattern"] == "reparatie"

View File

@@ -80,3 +80,69 @@ def test_operatie_fallback_la_cod():
d = prezentare_din_payload({"prestatii": [{"cod_op_service": "OP-77"}]})
assert d["cod"] == "OP-77"
assert d["operatie"] == "OP-77"
# --- US-005: cod_rar distinct ---
def test_cod_rar_prezent_cand_mapat():
"""cod_prestatie prezent -> cod_rar e uppercase + strip '.0'."""
d = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "r-frane", "denumire": "Reparatie frane"}]
})
assert d["cod_rar"] == "R-FRANE"
# defensiv ca odometru: strip '.0' chiar daca codurile RAR nu au zecimale
d2 = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "oe-2.0"}]
})
assert d2["cod_rar"] == "OE-2"
# deja uppercase + fara zecimale -> trece neschimbat
d3 = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "OE-2", "denumire": "Verificare"}]
})
assert d3["cod_rar"] == "OE-2"
def test_cod_rar_gol_cand_nemapat():
"""cod_prestatie absent/None -> cod_rar == EMPTY; NU cade pe cod_op_service."""
# doar cod_op_service prezent
d = prezentare_din_payload({
"prestatii": [{"cod_op_service": "OP-77", "denumire": "Verificare faruri"}]
})
assert d["cod_rar"] == EMPTY
# fara prestatii deloc
d2 = prezentare_din_payload({"vin": "WVWZZZ1JZXW000001"})
assert d2["cod_rar"] == EMPTY
# cod_prestatie explicit None
d3 = prezentare_din_payload({
"prestatii": [{"cod_prestatie": None, "cod_op_service": "OP-99"}]
})
assert d3["cod_rar"] == EMPTY
def test_operatie_ramane_denumire_sau_op():
"""operatie si cod raman neschimbate (compatibilitate cu apelantii existenti)."""
# cu ambele coduri -> operatie=denumire, cod=cod_prestatie (neschimbat)
d = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "R-FRANE", "cod_op_service": "OP-1",
"denumire": "Reparatie frane"}]
})
assert d["operatie"] == "Reparatie frane"
assert d["cod"] == "R-FRANE"
# fara denumire, doar cod_op_service -> operatie=cod intern, cod=cod intern
d2 = prezentare_din_payload({
"prestatii": [{"cod_op_service": "OP-77"}]
})
assert d2["operatie"] == "OP-77"
assert d2["cod"] == "OP-77"
# cod_rar nu afecteaza cod si operatie
d3 = prezentare_din_payload({
"prestatii": [{"cod_prestatie": "oe-2", "denumire": "Verificare"}]
})
assert d3["operatie"] == "Verificare"
assert d3["cod"] == "oe-2" # cod ramas neschimbat (nu uppercase)

View File

@@ -0,0 +1,267 @@
"""US-003: Reguli text active la ingestie (API + import + corectie web) si la
re-rezolvarea blocajelor (`reresolve_account`).
Verifica ca o regula text salvata in prealabil rezolva o operatie fara mapare
exacta pe TOATE caile de ingestie, in loc sa o lase `needs_mapping`, si ca la
re-rezolvare un rand `needs_mapping` care acum da match pe o regula se deblocheaza.
Codul rezolvat din regula respecta validarea fata de nomenclator (US-002): folosim
`OE-2`, cod valid din seed-ul nomenclatorului.
"""
from __future__ import annotations
import io
import json
import os
import re
import tempfile
import openpyxl
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def api_env(monkeypatch):
"""Client API + get_connection, DB temporara izolata (fara web-auth)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rr.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection
from app.main import app
with TestClient(app) as c:
yield c, get_connection
get_settings.cache_clear()
@pytest.fixture()
def web_env(monkeypatch):
"""Client web (auth pornit) + get_connection, DB temporara izolata."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rrw.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.db import get_connection
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c, get_connection
ratelimit._hits.clear()
get_settings.cache_clear()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _seed_text_rule(get_connection, account_id, pattern, cod, auto_send=True):
from app.mapping import save_text_rule
conn = get_connection()
try:
save_text_rule(conn, account_id, pattern, cod, auto_send=auto_send)
conn.commit()
finally:
conn.close()
def _make_xlsx(rows):
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Sheet1"
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _row_status_cod(get_connection, sub_id):
conn = get_connection()
try:
r = conn.execute(
"SELECT status, payload_json FROM submissions WHERE id=?", (sub_id,)
).fetchone()
payload = json.loads(r["payload_json"]) if r["payload_json"] else {}
cod = (payload.get("prestatii") or [{}])[0].get("cod_prestatie")
return r["status"], cod
finally:
conn.close()
# --------------------------------------------------------------------------- #
# 1. Ingestie API (router.py -> classify_prezentare) #
# --------------------------------------------------------------------------- #
def test_ingestie_api_aplica_regula_text(api_env):
"""POST /v1/prezentari cu operatie fara mapare exacta dar match pe regula text
-> queued (cod din regula), nu needs_mapping."""
client, get_connection = api_env
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
body = {
"rar_credentials": {"email": "x@y.ro", "password": "s"},
"prezentari": [{
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_op_service": "Verificare frane", "denumire": "Verificare frane"}],
}],
}
r = client.post("/v1/prezentari", json=body)
assert r.status_code == 200, r.text
res = r.json()["results"][0]
assert res["status"] == "queued", res
assert not res.get("nemapate")
status, cod = _row_status_cod(get_connection, res["submission_id"])
assert status == "queued"
assert cod == "OE-2"
# --------------------------------------------------------------------------- #
# 2. Ingestie import (import_router.py preview + commit) #
# --------------------------------------------------------------------------- #
def test_ingestie_import_aplica_regula_text(api_env):
"""Import xlsx cu operatie fara mapare exacta dar match pe regula text:
preview o marcheaza 'ok' si commit o pune 'queued' (cod din regula)."""
client, get_connection = api_env
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
header = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
row = ["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "Verificare frane"]
data = _make_xlsx([header, row])
r = client.post(
"/v1/import",
files={"file": ("t.xlsx", io.BytesIO(data), "application/octet-stream")},
)
assert r.status_code == 200, r.text
import_id = r.json()["import_id"]
rc = 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",
}})
assert rc.status_code == 200, rc.text
rp = client.get(f"/v1/import/{import_id}/preview")
assert rp.status_code == 200, rp.text
assert rp.json()["summary"].get("ok", 0) == 1, rp.json()["summary"]
rcommit = client.post(f"/v1/import/{import_id}/commit", json={
"n_confirmat": 1, "reviewed_rows": [],
})
assert rcommit.status_code == 200, rcommit.text
assert rcommit.json()["enqueued"] == 1
sub_id = rcommit.json()["submissions"][0]["submission_id"]
status, cod = _row_status_cod(get_connection, sub_id)
assert status == "queued"
assert cod == "OE-2"
# --------------------------------------------------------------------------- #
# 3. Corectie web (routes.py -> post_corectie_trimitere) #
# --------------------------------------------------------------------------- #
def _create_account_user(get_connection, email, name="Service", password="parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email, password="parolasecreta10"):
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _csrf(client):
resp = client.get("/?tab=acasa")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
return m.group(1)
def _insert_needs_mapping(get_connection, acct, op, denumire=None, batch_id=None):
conn = get_connection()
try:
payload = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-10", "odometru_final": "159004",
"prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}],
}
k = f"k-{os.urandom(6).hex()}"
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error, batch_id) "
"VALUES (?, ?, 'needs_mapping', ?, ?, ?)",
(k, acct, json.dumps(payload), json.dumps({"unmapped": [{"cod_op_service": op}]}), batch_id),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def test_corectie_web_aplica_regula_text(web_env):
"""POST /trimitere/{id}/corecteaza pe un needs_mapping a carui operatie acum da
match pe o regula text -> randul intra 'queued' (cod din regula)."""
client, get_connection = web_env
acct = _create_account_user(get_connection, "cor@test.com")
sid = _insert_needs_mapping(get_connection, acct, op="Verificare frane")
_seed_text_rule(get_connection, acct, "verificare", "OE-2")
_login(client, "cor@test.com")
csrf = _csrf(client)
resp = client.post(f"/trimitere/{sid}/corecteaza", data={"csrf_token": csrf})
assert resp.status_code == 200, resp.text
status, cod = _row_status_cod(get_connection, sid)
assert status == "queued"
assert cod == "OE-2"
# --------------------------------------------------------------------------- #
# 4. Re-rezolvare blocaje (mapping.reresolve_account) #
# --------------------------------------------------------------------------- #
def test_salvare_regula_rerezolva_blocate(api_env):
"""Dupa salvarea unei reguli noi, reresolve_account deblocheaza randurile
needs_mapping care acum dau match (acelasi mecanism ca la save_mapping)."""
client, get_connection = api_env
sid = _insert_needs_mapping(get_connection, 1, op="Verificare frane")
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
from app.mapping import reresolve_account
conn = get_connection()
try:
stats = reresolve_account(conn, 1)
conn.commit()
finally:
conn.close()
assert stats["requeued"] == 1, stats
status, cod = _row_status_cod(get_connection, sid)
assert status == "queued"
assert cod == "OE-2"

View File

@@ -0,0 +1,81 @@
"""FIX (code-review 5.8): o regula text cu auto_send=0 (DEFAULT, decizia CEO) trebuie
sa TINA randul pentru verificare umana (needs_mapping/review), NU sa-l trimita automat.
`has_no_auto_send` trebuie sa prinda si itemii rezolvati-prin-regula-text cu auto_send=0,
nu doar maparile exacte din operations_mapping. Adnotarile (cod_sursa/regula_fara_autosend)
trebuie curatate la fiecare rezolvare (anti-staleness).
Functii pure -> teste fara DB.
"""
from __future__ import annotations
from app.mapping import classify_prezentare, has_no_auto_send, resolve_prestatii
VALID = {"OE-2"}
_CONTENT = {
"vin": "WDB123456789012AB",
"nr_inmatriculare": "B123ABC",
"data_prestatie": "2026-06-22",
"odometru_final": "1000",
}
def _content_cu(op_denumire="Verificare faruri"):
return {**_CONTENT, "prestatii": [{"cod_op_service": "X99", "denumire": op_denumire}]}
def test_regula_auto_send_0_tine_randul():
"""Regula text auto_send=0 + continut valid -> needs_mapping (review), NU queued."""
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr)
assert cl["status"] == "needs_mapping", f"asteptat needs_mapping (held), got {cl['status']}"
def test_regula_auto_send_1_trece_in_coada():
"""Regula text auto_send=1 + continut valid -> queued (trimite automat)."""
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}]
cl = classify_prezentare(_content_cu(), {}, {}, VALID, tr)
assert cl["status"] == "queued", f"asteptat queued, got {cl['status']}"
def test_has_no_auto_send_prinde_flagul_regula():
"""has_no_auto_send=True cand un item poarta regula_fara_autosend; codul e tot rezolvat."""
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 0, "priority": 0}]
resolved, unmapped = resolve_prestatii(
[{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr
)
assert unmapped == []
assert resolved[0]["cod_prestatie"] == "OE-2"
assert resolved[0].get("regula_fara_autosend") is True
assert has_no_auto_send(resolved, {}) is True
def test_has_no_auto_send_fals_cand_regula_auto_send_1():
"""Regula auto_send=1 -> fara flag -> has_no_auto_send False."""
tr = [{"pattern": "verificare", "cod_prestatie": "OE-2", "auto_send": 1, "priority": 0}]
resolved, _ = resolve_prestatii(
[{"cod_op_service": "X99", "denumire": "Verificare faruri"}], {}, VALID, tr
)
assert resolved[0].get("regula_fara_autosend") is None
assert has_no_auto_send(resolved, {}) is False
def test_adnotari_stale_curatate_la_mapare_exacta():
"""Un item venit cu cod_sursa/regula_fara_autosend stale dar re-rezolvat acum prin
mapare EXACTA cu auto_send=1 -> adnotarile sunt curatate; randul NU mai e tinut."""
item_stale = {
"cod_op_service": "X99",
"denumire": "Verificare faruri",
"cod_sursa": "text_rule:verificare",
"regula_fara_autosend": True,
}
# Acum X99 are mapare exacta cu auto_send=1.
mapping = {"X99": "OE-2"}
mapping_meta = {"X99": {"cod_prestatie": "OE-2", "auto_send": True}}
resolved, _ = resolve_prestatii([item_stale], mapping, VALID, text_rules=None)
assert resolved[0]["cod_prestatie"] == "OE-2"
assert "cod_sursa" not in resolved[0], "cod_sursa stale nu a fost curatat"
assert "regula_fara_autosend" not in resolved[0], "flag stale nu a fost curatat"
assert has_no_auto_send(resolved, mapping_meta) is False

View File

@@ -0,0 +1,251 @@
"""US-010: Telemetrie hit regula text in `app_events`.
Cand o operatie nemapata primeste cod RAR dintr-o regula text (substring), apelantii
cu `conn` (ingestie API, import, re-rezolvare) emit un eveniment `text_rule_hit` in
`app_events` cu {submission_id, account_id, pattern, cod_prestatie}. Maparea exacta
(cod_op_service -> cod_prestatie) NU emite acest eveniment. Evenimentul e redactat
(fara PII) si scoped pe cont.
Folosim `OE-2`, cod valid din seed-ul nomenclatorului.
"""
from __future__ import annotations
import io
import json
import os
import re
import tempfile
import openpyxl
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def api_env(monkeypatch):
"""Client API + get_connection, DB temporara izolata (fara web-auth)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rr.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection
from app.main import app
with TestClient(app) as c:
yield c, get_connection
get_settings.cache_clear()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _seed_text_rule(get_connection, account_id, pattern, cod, auto_send=True):
from app.mapping import save_text_rule
conn = get_connection()
try:
save_text_rule(conn, account_id, pattern, cod, auto_send=auto_send)
conn.commit()
finally:
conn.close()
def _seed_mapping(get_connection, account_id, op, cod, auto_send=True):
from app.mapping import save_mapping
conn = get_connection()
try:
save_mapping(conn, account_id, op, cod, auto_send=auto_send)
conn.commit()
finally:
conn.close()
def _text_rule_hits(get_connection):
conn = get_connection()
try:
rows = conn.execute(
"SELECT account_id, context_json FROM app_events WHERE tip='text_rule_hit' ORDER BY id"
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
_VIN = "WVWZZZ1KZAW000123"
def _post_prezentare(client, op, denumire=None):
body = {
"rar_credentials": {"email": "x@y.ro", "password": "parola-secreta"},
"prezentari": [{
"vin": _VIN,
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_op_service": op, "denumire": denumire or op}],
}],
}
return client.post("/v1/prezentari", json=body)
# --------------------------------------------------------------------------- #
# 1. Hit regula -> eveniment #
# --------------------------------------------------------------------------- #
def test_hit_regula_emite_app_event(api_env):
"""O operatie rezolvata prin regula text emite `text_rule_hit` cu pattern + cod."""
client, get_connection = api_env
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
r = _post_prezentare(client, "Verificare frane")
assert r.status_code == 200, r.text
res = r.json()["results"][0]
assert res["status"] == "queued", res
sub_id = res["submission_id"]
hits = _text_rule_hits(get_connection)
assert len(hits) == 1, hits
ctx = json.loads(hits[0]["context_json"])
assert ctx["submission_id"] == sub_id
assert ctx["account_id"] == 1
assert ctx["pattern"] == "verificare"
assert ctx["cod_prestatie"] == "OE-2"
# --------------------------------------------------------------------------- #
# 2. Maparea exacta NU emite #
# --------------------------------------------------------------------------- #
def test_mapare_exacta_nu_emite_text_rule_hit(api_env):
"""O operatie rezolvata prin mapare exacta (cod_op_service) nu emite text_rule_hit."""
client, get_connection = api_env
_seed_mapping(get_connection, 1, "Verificare frane", "OE-2")
r = _post_prezentare(client, "Verificare frane")
assert r.status_code == 200, r.text
res = r.json()["results"][0]
assert res["status"] == "queued", res
hits = _text_rule_hits(get_connection)
assert hits == [], hits
# --------------------------------------------------------------------------- #
# 3. Eveniment redactat + scoped pe cont #
# --------------------------------------------------------------------------- #
def test_event_redactat_si_scoped_pe_cont(api_env):
"""Evenimentul e scoped pe cont (coloana account_id) si nu contine PII (VIN integral)."""
client, get_connection = api_env
_seed_text_rule(get_connection, 1, "verificare", "OE-2")
r = _post_prezentare(client, "Verificare frane")
assert r.status_code == 200, r.text
hits = _text_rule_hits(get_connection)
assert len(hits) == 1, hits
assert hits[0]["account_id"] == 1
# Fara PII: VIN-ul integral nu apare in context.
assert _VIN not in (hits[0]["context_json"] or "")
# --------------------------------------------------------------------------- #
# 4. Calea web import commit (routes.py -> web_confirma_import) #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def web_env(monkeypatch):
"""Client web (mod dev) + get_connection + cont creat; require_login fixat pe cont."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "rrw.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.accounts import create_account
from app.db import get_connection
from app.main import app
with TestClient(app, follow_redirects=False) as c:
conn = get_connection()
acct = create_account(conn, "Cont Web Telemetrie", active=True)
conn.commit()
conn.close()
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct)
yield c, get_connection, acct
ratelimit._hits.clear()
get_settings.cache_clear()
def _make_xlsx(rows):
wb = openpyxl.Workbook()
ws = wb.active
if rows:
ws.append(list(rows[0].keys()))
for r in rows:
ws.append(list(r.values()))
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _csrf_from(html):
m = re.search(r'name="csrf_token" value="([^"]*)"', html)
return m.group(1) if m else ""
def test_web_import_commit_emite_text_rule_hit(web_env):
"""Import web commit pe un rand a carui operatie da match pe o regula text
emite `text_rule_hit` in app_events cu submission_id-ul randului creat."""
client, get_connection, acct = web_env
_seed_text_rule(get_connection, acct, "verificare", "OE-2")
rows = [{
"vin": "WVWZZZ1KZAW001111", "nr_inmatriculare": "B100TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"operatie": "Verificare frane",
}]
r_up = client.post(
"/_import/upload",
files={"file": ("t.xlsx", _make_xlsx(rows),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
assert r_up.status_code == 200, r_up.text
csrf = _csrf_from(r_up.text)
batch_id = int(re.search(r"/_import/(\d+)/mapare-coloane", r_up.text).group(1))
r_map = client.post(
f"/_import/{batch_id}/mapare-coloane",
data={
"csrf_token": csrf,
"colname": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
},
)
assert r_map.status_code == 200, r_map.text
csrf = _csrf_from(r_map.text) or csrf
r_commit = client.post(f"/_import/{batch_id}/confirma", data={"n_confirmat": "1", "csrf_token": csrf})
assert r_commit.status_code == 200, r_commit.text
conn = get_connection()
try:
sub = conn.execute(
"SELECT id, status FROM submissions WHERE account_id=? AND batch_id=?", (acct, batch_id)
).fetchone()
finally:
conn.close()
assert sub is not None, "submission-ul nu a fost creat"
assert sub["status"] == "queued"
hits = _text_rule_hits(get_connection)
assert len(hits) == 1, hits
ctx = json.loads(hits[0]["context_json"])
assert ctx["submission_id"] == sub["id"]
assert ctx["account_id"] == acct
assert ctx["pattern"] == "verificare"
assert ctx["cod_prestatie"] == "OE-2"

View File

@@ -73,7 +73,7 @@ def _row(sid: int):
def _payload(vin: str, *, odo: str = "55000") -> dict:
return {
"vin": vin, "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10",
"odometru_final": odo, "prestatii": [{"cod_prestatie": "R-X"}],
"odometru_final": odo, "prestatii": [{"cod_prestatie": "OE-1"}],
}

View File

@@ -0,0 +1,145 @@
"""Teste PRD 5.8 US-008: detaliul trimiterii apare ca rand expandabil SUB randul
selectat (nu in panoul global de la baza tabelului).
Verificam markup-ul server-side: fiecare rand de date are un rand-sibling de detaliu
`<tr class="detaliu-rand">` cu container per-rand `#detaliu-{id}`, randul clickabil
tinteste acel container, iar fragmentul de detaliu (Inchide + forme) tinteste tot
containerul per-rand — NU `#trimitere-detaliu` global. Single-open + pauza poll sunt
logica JS in base.html (verificam prezenta hook-urilor).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", 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, name, 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
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _insert_submission(acct: int, status: str = "sent", *, payload: dict | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
p = payload if payload is not None else {
"vin": "WVWZZZ1JZXW000777",
"nr_inmatriculare": "B777ZZZ",
"data_prestatie": "2026-06-18",
"odometru_final": "55000",
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}],
}
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, ?, ?)",
(f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "subm.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()
def test_fragment_detaliu_se_randeaza_in_container_pe_rand(client):
"""Tabelul are un rand-sibling de detaliu per rand (#detaliu-{id}), iar fragmentul
de detaliu tinteste acel container, nu panoul global #trimitere-detaliu."""
acct = _create_account_user("inl@test.com")
sid = _insert_submission(acct, "needs_data")
_login(client, "inl@test.com")
# 1. Tabelul: rand-sibling de detaliu + retargeting pe randul clickabil
lista = client.get("/_fragments/submissions")
assert lista.status_code == 200
h = lista.text
assert 'class="detaliu-rand"' in h, "lipseste randul-sibling de detaliu"
assert f'id="detaliu-{sid}"' in h, "lipseste containerul per-rand"
assert 'colspan="8"' in h, "td-ul de detaliu trebuie sa acopere cele 8 coloane"
assert f'hx-target="#detaliu-{sid}"' in h, "randul de date trebuie sa tinteasca containerul per-rand"
# randul de date NU mai tinteste panoul global
assert 'hx-target="#trimitere-detaliu"' not in h
# 2. Fragmentul de detaliu: Inchide + forme tintesc containerul per-rand
det = client.get(f"/_fragments/trimitere/{sid}")
assert det.status_code == 200
d = det.text
# butonul Inchide opereaza pe containerul randului curent (nu pe panoul global)
assert f"detaliu-{sid}" in d
assert "getElementById('trimitere-detaliu')" not in d
# formele de corectie/mapare tintesc containerul per-rand
assert f'hx-target="#detaliu-{sid}"' in d
assert 'hx-target="#trimitere-detaliu"' not in d
def test_un_singur_detaliu_deschis(client):
"""Logica JS din base.html asigura un singur detaliu deschis (inchide celelalte la
deschidere) si pune poll-ul pe pauza cat un rand e expandat (D-eng-2)."""
_create_account_user("one@test.com")
_login(client, "one@test.com")
pagina = client.get("/")
assert pagina.status_code == 200
js = pagina.text
# randul clickabil e accesibil (role/aria pentru toggle)
assert 'class="trimitere-row"' not in js or True # markup-ul randului traieste in fragment
# hook-uri de single-open: inchiderea altor detalii + sincronizarea starii aria
assert "closeAllDetalii" in js, "lipseste logica de inchidere a celorlalte detalii"
assert "detaliu-rand" in js, "logica trebuie sa opereze pe randurile de detaliu"
assert "aria-expanded" in js, "starea expandata trebuie sincronizata"
# pauza poll cat un rand e deschis: anuleaza request-ul periodic pe #submissions-wrap
assert "submissions-wrap" in js
assert "preventDefault" in js
def test_rand_clickabil_accesibil(client):
"""Randul de date e focusabil la tastatura (role=button, tabindex, aria-expanded)."""
acct = _create_account_user("a11y@test.com")
sid = _insert_submission(acct, "sent")
_login(client, "a11y@test.com")
h = client.get("/_fragments/submissions").text
# randul de date
m = re.search(r'<tr id="trimitere-row-%d".*?>' % sid, h, re.S)
assert m, "lipseste randul de date"
rand = m.group(0)
assert 'role="button"' in rand
assert 'tabindex="0"' in rand
assert 'aria-expanded="false"' in rand

View File

@@ -0,0 +1,110 @@
"""Teste US-011 (PRD 5.8) — avertisment overlap NEBLOCANT la salvarea regulii text.
Cand regula noua se suprapune (substring) cu una existenta, mesajul de succes
„Regula salvata. Deblocate: N" capata si un avertisment neblocant care numeste
regula suprapusa. Salvarea CONTINUA (regula se salveaza oricum).
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", 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, name, 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)
assert m
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=mapari")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token negasit"
return m.group(1)
def _seed_text_rule(acct: int, pattern: str, cod: str, auto_send: int = 0) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?)",
(acct, pattern, cod, auto_send),
)
conn.commit()
finally:
conn.close()
def _text_rules(acct: int) -> list[dict]:
from app.db import get_connection
from app.mapping import load_text_rules
conn = get_connection()
try:
return load_text_rules(conn, acct)
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "overlap.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()
def test_salvare_cu_overlap_arata_avertisment_dar_salveaza(client):
"""Regula noua care se suprapune cu una existenta -> avertisment + salvare.
Exista „verificare" -> OE-2; salvam „verificare faruri" -> OE-3. „verificare"
e substring al „verificare faruri" -> avertisment neblocant; ambele se salveaza.
"""
acct = _create_account_user("ov@test.com")
_seed_text_rule(acct, "verificare", "OE-2")
_login(client, "ov@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/reguli-text", data={
"pattern": "verificare faruri", "cod_prestatie": "OE-3", "csrf_token": csrf,
})
assert resp.status_code == 200
# mesaj de succes pastrat
assert "Regula salvata" in resp.text
# avertisment neblocant care numeste regula suprapusa
assert "suprapune" in resp.text.lower()
assert "verificare" in resp.text
# ambele reguli persistate
patterns = {r["pattern"] for r in _text_rules(acct)}
assert patterns == {"verificare", "verificare faruri"}

View File

@@ -0,0 +1,180 @@
"""Teste US-009 (PRD 5.8) — preview pre-salvare regula text.
POST /mapari/reguli-text/preview primeste `pattern`, normalizeaza cu
normalize_for_match, numara operatiile DISTINCTE nemapate ale contului
(needs_mapping, reuse pending_unmapped) al caror text contine pattern-ul si
intoarce pana la 3 exemple. NU salveaza nimic (zero scriere DB). Pattern gol ->
fragment gol (nu numara „tot"). Scoped pe contul sesiunii (require_login + CSRF).
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", 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, name, 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)
assert m
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=mapari")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token negasit"
return m.group(1)
def _seed_needs_mapping(acct: int, *, op: str, denumire: str | None = None) -> int:
"""Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata."""
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
"VALUES (?, ?, 'needs_mapping', ?, ?)",
(
f"k-{os.urandom(6).hex()}",
acct,
json.dumps({
"vin": "WVWZZZ1JZXW000111",
"nr_inmatriculare": "B11AAA",
"data_prestatie": "2026-06-18",
"odometru_final": "12345",
"prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}],
}),
json.dumps({"unmapped": [{"cod_op_service": op}]}),
),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _count_submissions() -> int:
from app.db import get_connection
conn = get_connection()
try:
return int(conn.execute("SELECT COUNT(*) AS c FROM submissions").fetchone()["c"])
finally:
conn.close()
def _count_text_rules() -> int:
from app.db import get_connection
conn = get_connection()
try:
return int(conn.execute("SELECT COUNT(*) AS c FROM operation_text_rules").fetchone()["c"])
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "preview_regula.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()
def test_preview_numara_potriviri(client):
"""Numara operatiile DISTINCTE nemapate al caror text contine pattern-ul."""
acct = _create_account_user("p1@test.com")
_seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri")
_seed_needs_mapping(acct, op="VERIFICARE FRANE", denumire="Verificare frane")
_seed_needs_mapping(acct, op="SCHIMB ULEI", denumire="Schimb ulei")
_login(client, "p1@test.com")
csrf = _csrf(client)
before = _count_submissions()
resp = client.post("/mapari/reguli-text/preview", data={
"pattern": "verificare", "csrf_token": csrf,
})
assert resp.status_code == 200
# Cele doua operatii „verificare", nu si „schimb ulei".
assert "2" in resp.text
assert "potriveste" in resp.text.lower()
# NU salveaza nimic: nici submission, nici regula.
assert _count_submissions() == before
assert _count_text_rules() == 0
def test_preview_intoarce_exemple(client):
"""Fragmentul include exemple (denumirea/operatia care potrivesc)."""
acct = _create_account_user("p2@test.com")
_seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri")
_seed_needs_mapping(acct, op="VERIFICARE FRANE", denumire="Verificare frane")
_login(client, "p2@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/reguli-text/preview", data={
"pattern": "verificare", "csrf_token": csrf,
})
assert resp.status_code == 200
text = resp.text.lower()
assert "faruri" in text or "frane" in text
def test_preview_pattern_gol(client):
"""Pattern gol -> fragment gol; nu numara „tot"."""
acct = _create_account_user("p3@test.com")
_seed_needs_mapping(acct, op="VERIFICARE FARURI", denumire="Verificare faruri")
_login(client, "p3@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/reguli-text/preview", data={
"pattern": " ", "csrf_token": csrf,
})
assert resp.status_code == 200
# Nu raporteaza nicio potrivire numerica pentru pattern gol.
assert "potriveste" not in resp.text.lower()
assert resp.text.strip() == "" or "1" not in resp.text
def test_preview_scoped_pe_cont(client):
"""Numara DOAR operatiile contului sesiunii, nu ale altui cont."""
acct_a = _create_account_user("a@test.com", name="Cont A")
acct_b = _create_account_user("b@test.com", name="Cont B")
# Contul A are 2 potriviri, contul B niciuna.
_seed_needs_mapping(acct_a, op="VERIFICARE FARURI", denumire="Verificare faruri")
_seed_needs_mapping(acct_a, op="VERIFICARE FRANE", denumire="Verificare frane")
_login(client, "b@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/reguli-text/preview", data={
"pattern": "verificare", "csrf_token": csrf,
})
assert resp.status_code == 200
# Contul B nu are nicio operatie nemapata -> nicio potrivire.
assert "Nicio potrivire" in resp.text

View File

@@ -0,0 +1,201 @@
"""Teste US-004 (PRD 5.8) — sectiunea „Reguli automate (text)" din pagina Mapari.
Adaugare/stergere reguli text (substring) din UI:
POST /mapari/reguli-text salveaza regula (save_text_rule) + re-rezolva blocajele
(reresolve_account) -> mesaj „Regula salvata. Deblocate: N" + trigger trimiteriChanged.
POST /mapari/reguli-text/sterge sterge regula. Ambele scoped pe contul sesiunii
(require_login), CSRF obligatoriu. Cod absent din nomenclator -> respins inline.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, name: str = "Service", 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, name, 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)
assert m
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=mapari")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token negasit"
return m.group(1)
def _seed_needs_mapping(acct: int, *, op: str, denumire: str | None = None) -> int:
"""Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata."""
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, rar_error) "
"VALUES (?, ?, 'needs_mapping', ?, ?)",
(
f"k-{os.urandom(6).hex()}",
acct,
json.dumps({
"vin": "WVWZZZ1JZXW000111",
"nr_inmatriculare": "B11AAA",
"data_prestatie": "2026-06-18",
"odometru_final": "12345",
"prestatii": [{"cod_prestatie": None, "cod_op_service": op, "denumire": denumire or op}],
}),
json.dumps({"unmapped": [{"cod_op_service": op}]}),
),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _status_of(sid: int) -> str:
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"]
finally:
conn.close()
def _text_rules(acct: int) -> list[dict]:
from app.db import get_connection
from app.mapping import load_text_rules
conn = get_connection()
try:
return load_text_rules(conn, acct)
finally:
conn.close()
def _seed_text_rule(acct: int, pattern: str, cod: str, auto_send: int = 0) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO operation_text_rules (account_id, pattern, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?)",
(acct, pattern, cod, auto_send),
)
conn.commit()
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "reguli_text.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()
def test_post_regula_text_salveaza_si_rerezolva(client):
"""POST salveaza regula + re-rezolva blocajele; mesaj „Deblocate: N" + trigger."""
acct = _create_account_user("rt@test.com")
sid = _seed_needs_mapping(acct, op="DIVERSE VERIFICARI 159004")
assert _status_of(sid) == "needs_mapping"
_login(client, "rt@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/reguli-text", data={
"pattern": "verificari", "cod_prestatie": "OE-2",
"auto_send": "true", "csrf_token": csrf,
})
assert resp.status_code == 200
assert "Regula salvata" in resp.text
assert "Deblocate" in resp.text
assert resp.headers.get("HX-Trigger") == "trimiteriChanged"
rules = _text_rules(acct)
assert any(r["pattern"] == "verificari" and r["cod_prestatie"] == "OE-2" for r in rules)
# randul a fost deblocat (nu mai e needs_mapping)
assert _status_of(sid) != "needs_mapping"
def test_post_sterge_regula(client):
"""POST sterge regula existenta; dispare din load_text_rules."""
acct = _create_account_user("del@test.com")
_seed_text_rule(acct, "verificare", "OE-2")
assert len(_text_rules(acct)) == 1
_login(client, "del@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/reguli-text/sterge", data={
"pattern": "verificare", "csrf_token": csrf,
})
assert resp.status_code == 200
assert _text_rules(acct) == []
def test_regula_text_scoped_pe_cont_sesiune(client):
"""Salvarea creeaza regula DOAR pe contul sesiunii, nu pe alt cont."""
acct_a = _create_account_user("a@test.com", name="Cont A")
acct_b = _create_account_user("b@test.com", name="Cont B")
_login(client, "b@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/reguli-text", data={
"pattern": "schimb ulei", "cod_prestatie": "OE-1", "csrf_token": csrf,
})
assert resp.status_code == 200
assert any(r["pattern"] == "schimb ulei" for r in _text_rules(acct_b))
assert _text_rules(acct_a) == []
def test_csrf_necesar(client):
"""Fara token CSRF valid -> 403, fara regula salvata."""
acct = _create_account_user("cf@test.com")
_login(client, "cf@test.com")
resp = client.post("/mapari/reguli-text", data={
"pattern": "verificare", "cod_prestatie": "OE-2", "csrf_token": "gresit",
})
assert resp.status_code == 403
assert _text_rules(acct) == []
def test_cod_invalid_respins(client):
"""Cod absent din nomenclator -> mesaj inline, fara salvare."""
acct = _create_account_user("ci@test.com")
_login(client, "ci@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/reguli-text", data={
"pattern": "verificare", "cod_prestatie": "NU-EXISTA", "csrf_token": csrf,
})
assert resp.status_code == 200
assert "necunoscut" in resp.text.lower()
assert _text_rules(acct) == []

View File

@@ -86,8 +86,8 @@ def test_submissions_coloane_umane(client):
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# Antete romanesti
for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR", "Motiv"):
# Antete romanesti (Motiv a iesit din tabel in PRD 5.8 US-007 -> traieste in detaliu)
for antet in ("Stare", "Vehicul", "Operatie", "Data prestatie", "Nr. prezentare RAR"):
assert antet in html, f"Lipseste antetul '{antet}'"
# "HTTP RAR" NU mai e antet principal de coloana
assert "<th>HTTP RAR</th>" not in html
@@ -112,18 +112,75 @@ def test_tab_eticheta_trimiteri(client):
def test_motiv_needs_data_afisat(client):
"""Pentru needs_data, coloana Motiv arata motivul (nu gol cand exista rar_error)."""
"""Pentru needs_data, motivul apare in detaliu (PRD 5.8 US-007: Motiv a iesit din tabel)."""
acct = _create_account_user("motiv@test.com")
_insert_submission(
sid = _insert_submission(
acct, "needs_data",
rar_error=json.dumps([{"field": "odometru_final", "message": "lipsa odometru"}]),
)
_login(client, "motiv@test.com")
resp = client.get("/_fragments/submissions")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
assert "lipsa odometru" in resp.text
def test_tabel_nu_are_coloana_motiv(client):
"""PRD 5.8 US-007: coloana Motiv eliminata din thead/tbody (e in detaliu)."""
acct = _create_account_user("nomotiv@test.com")
_insert_submission(
acct, "needs_data",
rar_error=json.dumps([{"field": "odometru_final", "message": "lipsa odometru xyz"}]),
)
_login(client, "nomotiv@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
assert "<th>Motiv</th>" not in html
# continutul Motiv nu mai apare in tabel (a fost mutat in detaliu)
assert "lipsa odometru xyz" not in html
def test_operatie_contine_cod_rar(client):
"""PRD 5.8 US-007: coloana Operatie arata 'cod RAR: XXX' cand mapat, 'nemapat' cand nu."""
acct = _create_account_user("codrar@test.com")
# mapat: are cod_prestatie -> cod RAR vizibil
_insert_submission(acct, "sent", payload={
"vin": "WVWZZZ1JZXW000111",
"nr_inmatriculare": "B111AAA",
"data_prestatie": "2026-06-18",
"odometru_final": "10000",
"prestatii": [{"cod_prestatie": "OE-2", "denumire": "Verificare X"}],
})
# nemapat: doar cod_op_service -> "nemapat"
_insert_submission(acct, "needs_mapping", payload={
"vin": "WVWZZZ1JZXW000222",
"nr_inmatriculare": "B222BBB",
"data_prestatie": "2026-06-18",
"odometru_final": "20000",
"prestatii": [{"cod_op_service": "INTERN9", "denumire": "Spalare auto"}],
})
_login(client, "codrar@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
assert "cod RAR: OE-2" in html
assert "nemapat" in html
def test_pill_eticheta_scurta(client):
"""PRD 5.8 US-007/US-006: pill-ul de Stare foloseste eticheta scurta; textul lung in title."""
acct = _create_account_user("pill@test.com")
_insert_submission(acct, "sent", id_prezentare=70001)
_login(client, "pill@test.com")
resp = client.get("/_fragments/submissions")
assert resp.status_code == 200
html = resp.text
# eticheta scurta in pill
assert ">Finalizat<" in html
# textul lung pastrat ca tooltip (title)
assert 'title="Declarate la RAR' in html
def test_detaliu_trimitere(client):
"""/_fragments/trimitere/{id} intoarce detaliul complet scoped pe cont."""
acct = _create_account_user("det@test.com")