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:
63
tests/test_labels.py
Normal file
63
tests/test_labels.py
Normal 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"
|
||||
)
|
||||
@@ -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) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
53
tests/test_mapping_overlap.py
Normal file
53
tests/test_mapping_overlap.py
Normal 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"
|
||||
147
tests/test_mapping_text_rules.py
Normal file
147
tests/test_mapping_text_rules.py
Normal 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"
|
||||
@@ -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)
|
||||
|
||||
267
tests/test_reresolve_text_rules.py
Normal file
267
tests/test_reresolve_text_rules.py
Normal 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"
|
||||
81
tests/test_text_rule_autosend.py
Normal file
81
tests/test_text_rule_autosend.py
Normal 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
|
||||
251
tests/test_text_rule_telemetry.py
Normal file
251
tests/test_text_rule_telemetry.py
Normal 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"
|
||||
@@ -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"}],
|
||||
}
|
||||
|
||||
|
||||
|
||||
145
tests/test_web_detaliu_inline.py
Normal file
145
tests/test_web_detaliu_inline.py
Normal 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
|
||||
110
tests/test_web_mapari_overlap.py
Normal file
110
tests/test_web_mapari_overlap.py
Normal 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"}
|
||||
180
tests/test_web_mapari_preview_regula.py
Normal file
180
tests/test_web_mapari_preview_regula.py
Normal 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
|
||||
201
tests/test_web_mapari_text_rules.py
Normal file
201
tests/test_web_mapari_text_rules.py
Normal 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) == []
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user