feat(web): dashboard compact — import pe Acasa, status cu bife, Trimiteri lizibile, Mapari complete (3.5)

Acasa = ecran de import (tab Import scos, ?tab=import->Acasa). Bara status
compacta pe 2 randuri cu bife accesibile (glife + text) + data formatata.
'Coada'->'Trimiteri': coloane RO, stare umana, detaliu la click in panou
dedicat. Mapari pe 3 sectiuni (de rezolvat / op salvate / formate coloane),
Cont doar cheie+creds. Filtrare Trimiteri, corectie inline needs_data cu
re-enqueue + detectie coliziune idempotency, badge contoare pe tab-uri.
Helper pur partajat payload_view.py (web + GET /v1/prezentari).
Backend trimitere (worker/idempotenta/mapping/schema) neatins. 483 teste.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-19 08:56:45 +00:00
parent d10e9db998
commit d7ba1195d4
29 changed files with 3241 additions and 233 deletions

View File

@@ -83,14 +83,14 @@ def test_submissions_fragment_scoped(env, monkeypatch):
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_a)
r = client.get("/_fragments/submissions")
assert r.status_code == 200
assert f"<td>{sub_a}</td>" in r.text
assert f"<td>{sub_b}</td>" not in r.text
assert f'id="trimitere-row-{sub_a}"' in r.text
assert f'id="trimitere-row-{sub_b}"' not in r.text
monkeypatch.setattr("app.web.routes.require_login", lambda r: acct_b)
r = client.get("/_fragments/submissions")
assert r.status_code == 200
assert f"<td>{sub_b}</td>" in r.text
assert f"<td>{sub_a}</td>" not in r.text
assert f'id="trimitere-row-{sub_b}"' in r.text
assert f'id="trimitere-row-{sub_a}"' not in r.text
def test_nelogat_redirect(monkeypatch):

View File

@@ -0,0 +1,82 @@
"""Teste US-003 (PRD 3.5): helper pur payload -> campuri afisabile.
Helper partajat web + API (DRY, eng review). Defensiv: nu arunca pe payload
malformat; tolerant la diferentele de chei intre canale (numar vs
numarInmatriculare) si la coercion Excel (odometru "123.0", VIN non-string).
"""
from __future__ import annotations
import json
from app.payload_view import prezentare_din_payload, EMPTY
def test_detalii_din_payload():
"""Payload complet -> toate campurile afisabile corecte."""
payload = json.dumps({
"vin": "WVWZZZ1JZXW000001",
"nr_inmatriculare": "B123XYZ",
"odometru_final": "123456",
"data_prestatie": "2026-06-18",
"prestatii": [{"cod_prestatie": "R-FRANE", "denumire": "Reparatie frane"}],
})
d = prezentare_din_payload(payload)
assert d["vehicul_nr"] == "B123XYZ"
assert d["vin"] == "WVWZZZ1JZXW000001"
assert "000001" in d["vin_scurt"] # trunchiat dar identificabil
assert d["operatie"] == "Reparatie frane"
assert d["cod"] == "R-FRANE"
assert d["data_prestatie"] == "2026-06-18"
assert d["odometru"] == "123456"
def test_payload_partial():
"""Campuri lipsa -> EMPTY, fara exceptie."""
d = prezentare_din_payload(json.dumps({"vin": "WVWZZZ1JZXW000002"}))
assert d["vin"] == "WVWZZZ1JZXW000002"
assert d["vehicul_nr"] == EMPTY
assert d["operatie"] == EMPTY
assert d["cod"] == EMPTY
assert d["data_prestatie"] == EMPTY
assert d["odometru"] == EMPTY
def test_payload_gol():
"""Payload gol / None -> toate EMPTY, fara exceptie."""
for p in (None, "", "{}", {}):
d = prezentare_din_payload(p)
assert d["vehicul_nr"] == EMPTY
assert d["vin"] == EMPTY
def test_payload_invalid():
"""JSON invalid / tip neasteptat -> fallback grijuliu (nu arunca)."""
for bad in ("nu-e-json", "[1,2,3]", "null", "12345"):
d = prezentare_din_payload(bad)
assert d["vin"] == EMPTY # degradeaza curat
def test_payload_coercion_excel():
"""Odometru '123.0'/numeric si VIN non-string afisate curat; chei API alternative."""
# Excel coercion: odometru float-string si numeric
d1 = prezentare_din_payload({"odometru_final": "123456.0"})
assert d1["odometru"] == "123456"
d2 = prezentare_din_payload({"odometru_final": 123456})
assert d2["odometru"] == "123456"
# VIN non-string (coercion Excel)
d3 = prezentare_din_payload({"vin": 12345678901234567})
assert d3["vin"] == "12345678901234567"
# Chei alternative canal API (numar / numarInmatriculare / odometru)
d4 = prezentare_din_payload({"numar": "CJ99ABC", "odometru": "777.0"})
assert d4["vehicul_nr"] == "CJ99ABC"
assert d4["odometru"] == "777"
d5 = prezentare_din_payload({"numarInmatriculare": "TM01AAA"})
assert d5["vehicul_nr"] == "TM01AAA"
def test_operatie_fallback_la_cod():
"""Fara denumire -> operatie afiseaza codul; cod intern cand lipseste cel RAR."""
d = prezentare_din_payload({"prestatii": [{"cod_op_service": "OP-77"}]})
assert d["cod"] == "OP-77"
assert d["operatie"] == "OP-77"

123
tests/test_web_badge.py Normal file
View File

@@ -0,0 +1,123 @@
"""Teste US-011 (PRD 3.5): badge cu contoare pe tab-uri (atentionari).
Badge doar cand contorul > 0; numar corect scoped pe cont; aria-label cu sens.
"""
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 _ins(acct: int, status: str) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(5).hex()}", acct, status, json.dumps({"vin": "X", "prestatii": []})),
)
conn.commit()
finally:
conn.close()
def _tab_link(html: str, elem_id: str) -> str:
"""Extrage tag-ul <a ...>...</a> al tab-ului cu id-ul dat."""
m = re.search(rf'<a id="{elem_id}".*?</a>', html, re.DOTALL)
assert m, f"Tab {elem_id} negasit"
return m.group(0)
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "badge.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_badge_mapari(client):
"""Cu operatii needs_mapping, tab-ul Mapari poarta un numar + aria-label."""
acct = _create_account_user("bm@test.com")
_ins(acct, "needs_mapping")
_ins(acct, "needs_mapping")
_login(client, "bm@test.com")
resp = client.get("/")
assert resp.status_code == 200
link = _tab_link(resp.text, "tab-mapari")
assert "tab-badge" in link
assert "2" in link
assert "necesita atentie" in link # aria-label
def test_badge_trimiteri_blocate(client):
"""Cu randuri blocate, tab-ul Trimiteri poarta marcaj."""
acct = _create_account_user("bt@test.com")
_ins(acct, "needs_data")
_ins(acct, "error")
_login(client, "bt@test.com")
resp = client.get("/")
assert resp.status_code == 200
link = _tab_link(resp.text, "tab-coada")
assert "tab-badge" in link
assert "2" in link
def test_badge_zero_ascuns(client):
"""Fara nimic de rezolvat, niciun badge."""
_create_account_user("bz@test.com")
_login(client, "bz@test.com")
resp = client.get("/")
assert resp.status_code == 200
assert "tab-badge" not in resp.text
def test_badge_scoped_pe_cont(client):
"""Badge-ul numara doar submission-urile contului propriu."""
acct1 = _create_account_user("bs1@test.com", name="C1")
_create_account_user("bs2@test.com", name="C2")
_ins(acct1, "needs_mapping")
_login(client, "bs2@test.com")
resp = client.get("/")
assert "tab-badge" not in resp.text # contul 2 nu are nimic

205
tests/test_web_corectie.py Normal file
View File

@@ -0,0 +1,205 @@
"""Teste US-010 (PRD 3.5): corectie inline pentru randuri ne-trimise blocate.
needs_data corectat valid -> queued cu payload + idempotency actualizate; sent
read-only (403); coliziune de idempotency prinsa pre-UPDATE (fara 500/duplicat);
cross-account interzis (404).
"""
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 _csrf(client) -> str:
resp = client.get("/?tab=coada")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
return m.group(1)
def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int:
from app.db import get_connection
conn = get_connection()
try:
k = key or f"k-{os.urandom(6).hex()}"
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(k, acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _row(sid: int):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
finally:
conn.close()
def _payload(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"}],
}
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "corectie.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_corectie_needs_data(client):
"""needs_data fara odometru -> completez odometru -> queued, payload + key actualizate."""
acct = _create_account_user("cd@test.com")
# needs_data: odometru gol
sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CD001", odo=""))
old_key = _row(sid)["idempotency_key"]
_login(client, "cd@test.com")
csrf = _csrf(client)
resp = client.post(f"/trimitere/{sid}/corecteaza", data={
"odometru_final": "77000", "csrf_token": csrf,
})
assert resp.status_code == 200
r = _row(sid)
assert r["status"] == "queued"
assert json.loads(r["payload_json"])["odometru_final"] == "77000"
assert r["idempotency_key"] != old_key # recalculata
assert r["rar_error"] is None
def test_corectie_inca_invalid_ramane_blocat(client):
"""Corectie cu date inca invalide -> ramane needs_data + mesaj de validare."""
acct = _create_account_user("ci@test.com")
sid = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CI001", odo=""))
_login(client, "ci@test.com")
csrf = _csrf(client)
# odometru tot invalid (non-numeric)
resp = client.post(f"/trimitere/{sid}/corecteaza", data={
"odometru_final": "abc", "csrf_token": csrf,
})
assert resp.status_code == 200
assert _row(sid)["status"] == "needs_data"
assert "odometruFinal" in resp.text # mesajul de validare e afisat
def test_corectie_sent_interzis(client):
"""Randurile sent NU pot fi editate (read-only -> 403)."""
acct = _create_account_user("cs@test.com")
sid = _insert(acct, status="sent", payload=_payload("WVWZZZ1JZXW0CS001"))
_login(client, "cs@test.com")
csrf = _csrf(client)
resp = client.post(f"/trimitere/{sid}/corecteaza", data={
"odometru_final": "88000", "csrf_token": csrf,
})
assert resp.status_code == 403
assert _row(sid)["status"] == "sent" # neschimbat
def test_corectie_coliziune_idempotency(client):
"""Daca noua cheie coincide cu alt submission -> oprire cu mesaj, fara 500/duplicat."""
from app.idempotency import build_key, canonicalize_row
acct = _create_account_user("cc@test.com")
target = _payload("WVWZZZ1JZXW0CC999", odo="99000")
existing_key = build_key(acct, canonicalize_row(target))
# B: submission existent cu cheia tinta
sid_b = _insert(acct, status="queued", payload=target, key=existing_key)
# A: needs_data, acelasi continut dar fara odometru
sid_a = _insert(acct, status="needs_data", payload=_payload("WVWZZZ1JZXW0CC999", odo=""))
_login(client, "cc@test.com")
csrf = _csrf(client)
resp = client.post(f"/trimitere/{sid_a}/corecteaza", data={
"odometru_final": "99000", "csrf_token": csrf,
})
assert resp.status_code == 200
assert "deja o trimitere identica" in resp.text
assert f"#{sid_b}" in resp.text
# A NU a fost re-pus in coada (a ramas blocat), B neatins
assert _row(sid_a)["status"] == "needs_data"
assert _row(sid_b)["idempotency_key"] == existing_key
def test_corectie_needs_mapping_nu_ajunge_in_coada(client):
"""Un rand needs_mapping cu cod nemapat NU trece in queued la corectie de continut
(altfel ar pleca la RAR cu codPrestatie null — FINALIZATA ireversibil)."""
acct = _create_account_user("cm@test.com")
payload = {
"vin": "WVWZZZ1JZXW0CM001", "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10",
"odometru_final": "", "prestatii": [{"cod_op_service": "OP-NEMAP", "denumire": "ceva"}],
}
sid = _insert(acct, status="needs_mapping", payload=payload)
_login(client, "cm@test.com")
csrf = _csrf(client)
# completez odometru, dar codul ramane nemapat
resp = client.post(f"/trimitere/{sid}/corecteaza", data={
"odometru_final": "70000", "csrf_token": csrf,
})
assert resp.status_code == 200
assert _row(sid)["status"] == "needs_mapping" # NU queued
assert "cod RAR" in resp.text.lower() or "mapari" in resp.text.lower()
def test_corectie_cont_strain(client):
"""Corectie pe randul altui cont -> 404 (fara leak)."""
acct1 = _create_account_user("ca1@test.com", name="C1")
_create_account_user("ca2@test.com", name="C2")
sid1 = _insert(acct1, status="needs_data", payload=_payload("WVWZZZ1JZXW0CA001", odo=""))
_login(client, "ca2@test.com")
csrf = _csrf(client)
resp = client.post(f"/trimitere/{sid1}/corecteaza", data={
"odometru_final": "10000", "csrf_token": csrf,
})
assert resp.status_code == 404
assert _row(sid1)["status"] == "needs_data" # neatins

100
tests/test_web_dashboard.py Normal file
View File

@@ -0,0 +1,100 @@
"""Teste US-002 (PRD 3.5): Acasa devine ecranul de import.
Upload direct pe prima pagina (importul = operatia principala); tab-ul "Import"
separat dispare, dar ?tab=import ramane valid (echivalent Acasa, fara 404).
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str = "dash@test.com", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Dash", 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
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "dash.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_acasa_contine_upload(client):
"""Fragmentul /_fragments/acasa contine formularul de upload (hx-post import)."""
_create_account_user("acasaup@test.com")
_login(client, "acasaup@test.com")
resp = client.get("/_fragments/acasa")
assert resp.status_code == 200
html = resp.text
assert 'hx-post="/_import/upload"' in html, "Acasa nu contine formularul de upload"
assert 'id="import-section"' in html, "Acasa nu contine zona de import"
def test_acasa_full_load_contine_upload(client):
"""La full load pe / (tab implicit Acasa) caseta de upload e vizibila direct."""
_create_account_user("acasafull@test.com")
_login(client, "acasafull@test.com")
resp = client.get("/")
assert resp.status_code == 200
assert 'hx-post="/_import/upload"' in resp.text
def test_tab_import_redirect(client):
"""?tab=import nu da 404; randeaza Acasa (echivalent), care contine upload-ul."""
_create_account_user("redir@test.com")
_login(client, "redir@test.com")
resp = client.get("/?tab=import")
assert resp.status_code == 200
html = resp.text
# Echivalent Acasa: contine upload-ul (import-section)
assert 'id="import-section"' in html
# Acasa e tab-ul activ (import nu mai e tab valid separat)
assert re.search(r'id="tab-acasa"[^>]*aria-selected="true"', html), \
"?tab=import ar trebui sa cada pe Acasa activ"
def test_tab_bar_fara_import(client):
"""Tab-bar-ul nu mai contine un tab 'Import' separat."""
_create_account_user("notab@test.com")
_login(client, "notab@test.com")
resp = client.get("/")
assert resp.status_code == 200
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', resp.text)

141
tests/test_web_filtrare.py Normal file
View File

@@ -0,0 +1,141 @@
"""Teste US-009 (PRD 3.5): filtrare/cautare in Trimiteri (stare/vehicul/data).
Toate scoped pe cont, fara leak cross-account.
"""
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 _ins(acct: int, *, status: str, vin: str, nr: str, data: str) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(
f"k-{os.urandom(5).hex()}", acct, status,
json.dumps({"vin": vin, "nr_inmatriculare": nr, "data_prestatie": data,
"odometru_final": "100", "prestatii": [{"cod_prestatie": "R-X"}]}),
),
)
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, "filtre.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_filtru_stare(client):
acct = _create_account_user("fs@test.com")
sid_q = _ins(acct, status="queued", vin="WVWZZZ1JZXW000001", nr="B001AAA", data="2026-06-10")
sid_nd = _ins(acct, status="needs_data", vin="WVWZZZ1JZXW000002", nr="B002BBB", data="2026-06-11")
_login(client, "fs@test.com")
resp = client.get("/_fragments/submissions?status=needs_data")
assert resp.status_code == 200
html = resp.text
assert f'id="trimitere-row-{sid_nd}"' in html
assert f'id="trimitere-row-{sid_q}"' not in html
def test_filtru_vehicul(client):
acct = _create_account_user("fv@test.com")
sid_a = _ins(acct, status="sent", vin="WVWZZZ1JZXW000111", nr="CJ77ABC", data="2026-06-10")
sid_b = _ins(acct, status="sent", vin="WVWZZZ1JZXW000222", nr="TM01XYZ", data="2026-06-11")
_login(client, "fv@test.com")
# cautare pe nr (case-insensitive)
resp = client.get("/_fragments/submissions?vehicul=cj77")
assert resp.status_code == 200
assert f'id="trimitere-row-{sid_a}"' in resp.text
assert f'id="trimitere-row-{sid_b}"' not in resp.text
# cautare pe fragment VIN
resp = client.get("/_fragments/submissions?vehicul=000222")
assert f'id="trimitere-row-{sid_b}"' in resp.text
assert f'id="trimitere-row-{sid_a}"' not in resp.text
def test_filtru_data(client):
acct = _create_account_user("fd@test.com")
sid_old = _ins(acct, status="sent", vin="WVWZZZ1JZXW000333", nr="B1", data="2026-06-01")
sid_new = _ins(acct, status="sent", vin="WVWZZZ1JZXW000444", nr="B2", data="2026-06-20")
_login(client, "fd@test.com")
resp = client.get("/_fragments/submissions?data_de=2026-06-15")
assert resp.status_code == 200
assert f'id="trimitere-row-{sid_new}"' in resp.text
assert f'id="trimitere-row-{sid_old}"' not in resp.text
resp = client.get("/_fragments/submissions?data_pana=2026-06-10")
assert f'id="trimitere-row-{sid_old}"' in resp.text
assert f'id="trimitere-row-{sid_new}"' not in resp.text
def test_filtru_scoped_cross_account(client):
acct1 = _create_account_user("fc1@test.com", name="C1")
acct2 = _create_account_user("fc2@test.com", name="C2")
sid1 = _ins(acct1, status="needs_data", vin="WVWZZZ1JZXW000555", nr="B1", data="2026-06-10")
sid2 = _ins(acct2, status="needs_data", vin="WVWZZZ1JZXW000666", nr="B2", data="2026-06-10")
_login(client, "fc2@test.com")
resp = client.get("/_fragments/submissions?status=needs_data")
assert f'id="trimitere-row-{sid2}"' in resp.text
assert f'id="trimitere-row-{sid1}"' not in resp.text
def test_empty_state_filtru_are_buton_sterge(client):
acct = _create_account_user("fe@test.com")
_ins(acct, status="sent", vin="WVWZZZ1JZXW000777", nr="B1", data="2026-06-10")
_login(client, "fe@test.com")
resp = client.get("/_fragments/submissions?status=needs_data")
assert resp.status_code == 200
assert "Nimic pe filtrul curent" in resp.text
assert "sterge filtrele" in resp.text

View File

@@ -0,0 +1,145 @@
"""Teste US-006 (PRD 3.5): listare + editare/stergere formate de coloane salvate.
Scoped pe cont (fara leak cross-account). Coloanele afisate = cheile json_mapare.
"""
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 _csrf(client) -> str:
resp = client.get("/?tab=mapari")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
return m.group(1)
def _seed_format(acct: int, sig: str, mapare: dict, fmt: str | None) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) "
"VALUES (?, ?, ?, ?)",
(acct, sig, json.dumps(mapare, ensure_ascii=False), fmt),
)
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, "formate.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_lista_formate_coloane(client):
"""Listarea intoarce formatele contului cu coloane + format_data."""
acct = _create_account_user("lf@test.com")
_seed_format(acct, "sig-1", {"Serie sasiu": "vin", "Nr auto": "nr_inmatriculare"}, "DD.MM.YYYY")
from app.db import get_connection
from app.web.routes import _load_column_formats
conn = get_connection()
try:
rows = _load_column_formats(conn, acct)
finally:
conn.close()
assert len(rows) == 1
assert rows[0]["format_data"] == "DD.MM.YYYY"
assert "Serie sasiu" in rows[0]["columns"]
assert rows[0]["mappings"]["Serie sasiu"] == "vin"
def test_editeaza_format_coloane(client):
"""POST schimba format_data pentru un format, scoped pe cont."""
acct = _create_account_user("ef@test.com")
fid = _seed_format(acct, "sig-2", {"Data": "data_prestatie"}, "DD.MM.YYYY")
_login(client, "ef@test.com")
csrf = _csrf(client)
resp = client.post("/formate-coloane/editeaza", data={
"format_id": str(fid), "format_data": "YYYY-MM-DD", "csrf_token": csrf,
})
assert resp.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT format_data FROM column_mappings WHERE id=?", (fid,)).fetchone()
finally:
conn.close()
assert row["format_data"] == "YYYY-MM-DD"
def test_sterge_format_coloane_scoped(client):
"""DELETE scoped pe cont: formatul altui cont ramane neatins (id strain ignorat)."""
acct1 = _create_account_user("sf1@test.com", name="C1")
acct2 = _create_account_user("sf2@test.com", name="C2")
fid1 = _seed_format(acct1, "sig-a", {"A": "vin"}, None)
fid2 = _seed_format(acct2, "sig-b", {"B": "vin"}, None)
_login(client, "sf1@test.com")
csrf = _csrf(client)
# Incearca sa stearga formatul altui cont -> ignorat (scoped pe id+account)
resp = client.post("/formate-coloane/sterge", data={"format_id": str(fid2), "csrf_token": csrf})
assert resp.status_code == 200
# Sterge formatul propriu -> ok
resp = client.post("/formate-coloane/sterge", data={"format_id": str(fid1), "csrf_token": csrf})
assert resp.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
r1 = conn.execute("SELECT 1 FROM column_mappings WHERE id=?", (fid1,)).fetchone()
r2 = conn.execute("SELECT 1 FROM column_mappings WHERE id=?", (fid2,)).fetchone()
finally:
conn.close()
assert r1 is None, "formatul propriu trebuia sters"
assert r2 is not None, "formatul altui cont NU trebuia sters (leak)"

View File

@@ -46,7 +46,12 @@ def _starile_din_schema() -> list[str]:
# Import modulul de etichete (va esua la RED, inainte de implementare)
# ---------------------------------------------------------------------------
from app.web.labels import eticheta_stare, eticheta_worker, eticheta_rar # noqa: E402
from app.web.labels import ( # noqa: E402
eticheta_stare,
eticheta_worker,
eticheta_rar,
format_data_rar,
)
# ---------------------------------------------------------------------------
@@ -128,6 +133,35 @@ def test_eticheta_stare_submission():
_STARI_SCHEMA = _starile_din_schema()
# ---------------------------------------------------------------------------
# Test format_data_rar (US-001, PRD 3.5)
# ---------------------------------------------------------------------------
def test_format_data_rar():
"""`2026-06-18T14:30:22` -> `18.06.2026 14:30:22`."""
assert format_data_rar("2026-06-18T14:30:22") == "18.06.2026 14:30:22"
def test_format_data_rar_cu_timezone():
"""Timezone si microsecunde nu strica formatarea; fractiunile cad."""
assert format_data_rar("2026-06-18T14:30:22.123456+00:00") == "18.06.2026 14:30:22"
assert format_data_rar("2026-06-18T14:30:22Z") == "18.06.2026 14:30:22"
def test_format_data_rar_lipsa():
"""Valoare lipsa -> em-dash, nu exceptie."""
assert format_data_rar(None) == ""
assert format_data_rar("") == ""
assert format_data_rar(" ") == ""
def test_format_data_rar_invalid():
"""Format invalid -> fallback grijuliu (intoarce brutul, nu arunca)."""
# Nu trebuie sa arunce
assert format_data_rar("nu-e-data") == "nu-e-data"
assert format_data_rar(12345) == "12345"
@pytest.mark.parametrize("status", _STARI_SCHEMA)
def test_toate_starile_au_eticheta(status: str):
"""

View File

@@ -0,0 +1,219 @@
"""Teste US-005 (PRD 3.5): listare + editare/stergere mapari operatii salvate.
Scoped pe cont (fara leak cross-account). Editarea respinge cod inexistent in
nomenclator si re-rezolva submission-urile blocate pe acel cod_op_service.
"""
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) 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 _csrf(client) -> str:
resp = client.get("/?tab=mapari")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit"
return m.group(1)
def _seed_nomenclator(cod: str, nume: str = "Test prestatie") -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
(cod, nume),
)
conn.commit()
finally:
conn.close()
def _seed_op_mapping(acct: int, op: str, cod: str, auto_send: int = 1) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?)",
(acct, op, cod, auto_send),
)
conn.commit()
finally:
conn.close()
def _seed_needs_mapping(acct: int, op: str) -> int:
"""Submission needs_mapping pe canal API (batch_id NULL) cu o operatie nemapata."""
from app.db import get_connection
import json
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, 'needs_mapping', ?)",
(
f"k-{op}-{os.urandom(4).hex()}",
acct,
json.dumps({
"vin": "WVWZZZ1JZXW000111",
"nr_inmatriculare": "B11AAA",
"data_prestatie": "2026-06-18",
"odometru_final": "12345",
"prestatii": [{"cod_op_service": op, "denumire": "ceva"}],
}),
),
)
conn.commit()
return 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()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_salvate.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_lista_mapari_salvate(client):
"""Listarea intoarce randurile operations_mapping ale contului cu nume_prestatie."""
acct = _create_account_user("lista@test.com")
_seed_nomenclator("R-FRANE", "Reparatie frane")
_seed_op_mapping(acct, "OP-100", "R-FRANE")
from app.db import get_connection
from app.web.routes import _load_saved_op_mappings
conn = get_connection()
try:
rows = _load_saved_op_mappings(conn, acct)
finally:
conn.close()
assert len(rows) == 1
assert rows[0]["cod_op_service"] == "OP-100"
assert rows[0]["cod_prestatie"] == "R-FRANE"
assert rows[0]["nume_prestatie"] == "Reparatie frane"
assert rows[0]["auto_send"] is True
def test_editeaza_mapare_salvata(client):
"""POST schimba cod_prestatie; respinge cod inexistent; scoped pe cont."""
acct = _create_account_user("edit@test.com")
_seed_nomenclator("R-FRANE", "Reparatie frane")
_seed_nomenclator("R-MOTOR", "Reparatie motor")
_seed_op_mapping(acct, "OP-100", "R-FRANE")
_login(client, "edit@test.com")
csrf = _csrf(client)
# Cod inexistent -> respins
resp = client.post("/mapari/salvate", data={
"cod_op_service": "OP-100", "cod_prestatie": "NU-EXISTA", "csrf_token": csrf,
})
assert resp.status_code == 200
assert "necunoscut" in resp.text.lower()
# Cod valid -> actualizat
resp = client.post("/mapari/salvate", data={
"cod_op_service": "OP-100", "cod_prestatie": "R-MOTOR", "auto_send": "true", "csrf_token": csrf,
})
assert resp.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute(
"SELECT cod_prestatie FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
(acct, "OP-100"),
).fetchone()
finally:
conn.close()
assert row["cod_prestatie"] == "R-MOTOR"
def test_editeaza_deblocheaza_submissions(client):
"""La editarea unui cod, submission-urile needs_mapping pe acel op se deblocheaza."""
acct = _create_account_user("debloc@test.com")
_seed_nomenclator("R-FRANE", "Reparatie frane")
sid = _seed_needs_mapping(acct, "OP-200")
assert _status_of(sid) == "needs_mapping"
_login(client, "debloc@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/salvate", data={
"cod_op_service": "OP-200", "cod_prestatie": "R-FRANE", "auto_send": "true", "csrf_token": csrf,
})
assert resp.status_code == 200
assert _status_of(sid) != "needs_mapping" # deblocat (queued sau needs_data)
def test_sterge_mapare_salvata_scoped(client):
"""DELETE scoped pe cont: maparea altui cont ramane neatinsa."""
acct1 = _create_account_user("st1@test.com", name="Cont1")
acct2 = _create_account_user("st2@test.com", name="Cont2")
_seed_nomenclator("R-FRANE")
_seed_op_mapping(acct1, "OP-X", "R-FRANE")
_seed_op_mapping(acct2, "OP-X", "R-FRANE")
_login(client, "st1@test.com")
csrf = _csrf(client)
resp = client.post("/mapari/salvate/sterge", data={"cod_op_service": "OP-X", "csrf_token": csrf})
assert resp.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
r1 = conn.execute("SELECT 1 FROM operations_mapping WHERE account_id=? AND cod_op_service='OP-X'", (acct1,)).fetchone()
r2 = conn.execute("SELECT 1 FROM operations_mapping WHERE account_id=? AND cod_op_service='OP-X'", (acct2,)).fetchone()
finally:
conn.close()
assert r1 is None, "maparea contului propriu trebuia stearsa"
assert r2 is not None, "maparea altui cont NU trebuia atinsa (leak)"

116
tests/test_web_mapari_ui.py Normal file
View File

@@ -0,0 +1,116 @@
"""Teste US-007 (PRD 3.5): pagina "Mapari" cu 3 sectiuni; "Cont" fara mapari."""
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 _seed(acct: int) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')")
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) VALUES (?, 'OP-1', 'R-FRANE', 1)",
(acct,),
)
conn.execute(
"INSERT INTO column_mappings (account_id, signature_coloane, json_mapare, format_data) VALUES (?, 'sig-x', ?, 'DD.MM.YYYY')",
(acct, json.dumps({"Serie sasiu": "vin"})),
)
conn.commit()
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "mapari_ui.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_mapari_trei_sectiuni(client):
"""Fragmentul /_fragments/mapari contine cele 3 sectiuni."""
acct = _create_account_user("m3@test.com")
_seed(acct)
_login(client, "m3@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
assert "De rezolvat" in html
assert "Mapari operatii salvate" in html
assert "Formate de coloane salvate" in html
# Maparea salvata si formatul apar
assert "OP-1" in html
assert "Serie sasiu" in html
def test_mapari_sectiuni_goale_au_mesaj(client):
"""Sectiunile goale au mesaj prietenos, nu lipsesc tacit."""
_create_account_user("mgol@test.com")
_login(client, "mgol@test.com")
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# Toate cele 3 titluri prezente chiar si cand sunt goale
assert "De rezolvat" in html
assert "Mapari operatii salvate" in html
assert "Formate de coloane salvate" in html
assert "Nicio mapare salvata" in html
assert "Niciun format de coloane salvat" in html
def test_cont_fara_mapari(client):
"""/_fragments/cont nu mai contine sectiuni de mapari."""
_create_account_user("cfm@test.com")
_login(client, "cfm@test.com")
resp = client.get("/_fragments/cont")
assert resp.status_code == 200
html = resp.text
assert "Mapari operatii salvate" not in html
assert "Formate de coloane salvate" not in html
# Cont contine doar cheie API + creds RAR
assert "Cheia mea API" in html
assert "Credentiale RAR" in html

View File

@@ -151,13 +151,10 @@ def test_checklist_pas_creds_bifat_cand_exista(client):
assert resp.status_code == 200
html = resp.text
# Cand exista creds, pasul trebuie sa fie bifat
# Verificam prezenta unui indicator de bifat (clasa 'bifat' sau 'pas-bifat' sau 'done')
# Cel putin unul dintre pattern-urile de bifat trebuie sa apara
assert re.search(
r'pas-bifat|class="[^"]*bifat|done.*RAR|RAR.*done|checkmark.*RAR|RAR.*checkmark',
html, re.DOTALL | re.IGNORECASE
), "Pasul RAR trebuie sa fie bifat cand contul are creds configurate"
# Cand exista creds, pasul "Cont RAR" e bifat: glifa ✓ (s-sent) langa link-ul Cont RAR
# (Acasa compacta PRD 3.5 — checklist pe un rand, bife cu glifa).
assert "&#10003;" in html, "Lipseste glifa de bifat cand contul are creds"
assert "Cont RAR" in html, "Lipseste pasul 'Cont RAR' din checklist"
# ============================================================
@@ -175,17 +172,12 @@ def test_checklist_ascuns_cand_totul_gata(client):
assert resp.status_code == 200
html = resp.text
# Cand totul e gata, ghidul compact/discret trebuie sa apara
# Fie "Totul e configurat" fie un link discret catre coada
assert "Totul e configurat" in html or "totul e configurat" in html.lower(), \
"Cand toti pasii sunt gata, trebuie sa apara mesajul discret 'Totul e configurat'"
# Cardul mare de pasi nu trebuie sa ocupe ecranul
# Verificam ca nu mai apare titlul mare al ghidului (Primii pasi)
# SAU ca ghidul e marcat ca colapsat (clasa 'ghid-complet' sau similar)
# Pattern: fie ghid-complet, fie lipsa titlului complet "Primii pasi" in forma de card mare
assert "ghid-complet" in html or "Totul e configurat" in html, \
"Ghidul trebuie sa se colapseze cand toti pasii esentiali sunt finalizati"
# Cand toti pasii esentiali sunt gata, checklist-ul "Primii pasi" dispare
# (Acasa compacta PRD 3.5: nu mai concureaza cu caseta de upload).
assert "Primii pasi" not in html, \
"Checklist-ul 'Primii pasi' trebuie sa dispara cand toti pasii esentiali sunt gata"
# Upload-ul ramane dominant pe pagina chiar si dupa setup complet
assert 'hx-post="/_import/upload"' in html
# ============================================================
@@ -193,7 +185,7 @@ def test_checklist_ascuns_cand_totul_gata(client):
# ============================================================
def test_linkuri_ghid_duc_la_taburi(client):
"""Link-urile din ghid contin ?tab=cont si ?tab=import."""
"""Ghidul Acasa duce la Cont; importul e direct pe pagina (nu mai e tab separat)."""
acct_id, _ = _create_account_user("links@test.com")
_login(client, "links@test.com")
@@ -205,9 +197,9 @@ def test_linkuri_ghid_duc_la_taburi(client):
assert "?tab=cont" in html, \
"Ghidul nu contine link catre tab-ul Cont (?tab=cont)"
# Ghidul trebuie sa contina link catre tab-ul Import
assert "?tab=import" in html, \
"Ghidul nu contine link catre tab-ul Import (?tab=import)"
# Importul e acum direct pe Acasa (caseta de upload), nu un link catre alt tab
assert 'hx-post="/_import/upload"' in html, \
"Acasa trebuie sa contina caseta de upload (importul e operatia principala)"
# ============================================================
@@ -227,13 +219,13 @@ def test_empty_state_coada_gol(client):
assert "POST /v1/prezentari" not in html, \
"Empty state coada nu trebuie sa contina mesajul tehnic vechi 'POST /v1/prezentari'"
# Trebuie sa contina un indemn catre Import
# Trebuie sa contina un indemn catre Import (acum pe Acasa)
assert "import" in html.lower() or "Import" in html, \
"Empty state coada trebuie sa contina indemn catre Import"
# Trebuie sa contina link catre ?tab=import
assert "?tab=import" in html, \
"Empty state coada trebuie sa contina link ?tab=import"
# Trebuie sa contina link catre Acasa (unde traieste importul acum)
assert "?tab=acasa" in html, \
"Empty state coada trebuie sa contina link catre Acasa (importul e acolo)"
# ============================================================

View File

@@ -0,0 +1,88 @@
"""Teste US-008 (PRD 3.5): preview-ul de import arata MOTIVUL randurilor respinse.
Un rand needs_data (ex. lipsa odometru) trebuie sa apara cu motivul explicit
(mesajul de validare), nu doar numarat la "blocate".
"""
from __future__ import annotations
import csv
import io
import os
import re
import tempfile
import pytest
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "prev.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _csv_bytes(rows: list[dict]) -> bytes:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=";")
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _seed_mapping_op1() -> None:
"""Mapeaza OP-1 -> R-FRANE (cont dev id=1) ca randurile sa nu fie needs_mapping."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES ('R-FRANE','Reparatie frane')")
conn.execute(
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (1, 'OP-1', 'R-FRANE', 1)"
)
conn.commit()
finally:
conn.close()
def test_preview_arata_motiv_needs_data(client):
"""Un rand fara odometru apare in preview cu motivul, nu doar numarat la blocate."""
_seed_mapping_op1()
rows = [
# rand valid
{"VIN": "WVWZZZ1KZAW000123", "Nr inmatriculare": "B001TST",
"Data prestatie": "15.06.2026", "Odometru final": "123456", "Operatie": "OP-1"},
# rand fara odometru -> needs_data
{"VIN": "WVWZZZ1KZAW000456", "Nr inmatriculare": "B002TST",
"Data prestatie": "15.06.2026", "Odometru final": "", "Operatie": "OP-1"},
]
data = _csv_bytes(rows)
# Upload -> formular mapare
r = client.post("/_import/upload", files={"file": ("test.csv", data, "text/csv")})
assert r.status_code == 200
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, "Nu am gasit import_id in formularul de mapare"
import_id = int(m.group(1))
# Salveaza maparea -> preview
r = client.post(f"/_import/{import_id}/mapare-coloane", data={
"colname": ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "DD.MM.YYYY",
})
assert r.status_code == 200
html = r.text
# Trebuie sa apara starea needs_data si MOTIVUL (mesajul de validare odometru)
assert "needs_data" in html, "Randul fara odometru trebuia marcat needs_data"
assert "odometruFinal" in html, (
"Preview-ul nu arata motivul (mesajul de validare) pentru randul fara odometru"
)

129
tests/test_web_status.py Normal file
View File

@@ -0,0 +1,129 @@
"""Teste US-001 (PRD 3.5): bara de status compacta cu bife accesibile + data formatata.
Bifa = glifa distincta (✓ / ✗) + text, NU doar culoare (daltonism, design review).
Verde/✓ cand worker viu + RAR ok; rosu/✗ cand oprit/indisponibil.
"""
from __future__ import annotations
import os
import re
import tempfile
from datetime import datetime, timezone
import pytest
from starlette.testclient import TestClient
def _create_account_user(email: str, password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test Bife", active=True)
user_id = create_user(conn, acct_id, email, password)
return acct_id, user_id
finally:
conn.close()
def _login(client, email: str, password: str) -> None:
resp = client.get("/login")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit pe /login"
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
def _set_heartbeat(last_beat: str | None, last_rar_login_ok: str | None) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE worker_heartbeat SET last_beat=?, last_rar_login_ok=? WHERE id=1",
(last_beat, last_rar_login_ok),
)
conn.commit()
finally:
conn.close()
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "bife_test.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_status_are_bife_verzi_cand_totul_ok(client):
"""Worker viu + RAR login recent -> bifa verde ✓ pentru ambele stari binare."""
_create_account_user("bifeok@test.com")
_login(client, "bifeok@test.com", "parolasecreta10")
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Glifa de OK prezenta (accesibilitate: nu doar culoare)
assert "&#10003;" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}"
# Texte umane de OK
assert "activa" in html.lower()
assert "functionala" in html.lower()
def test_status_are_bife_rosii_cand_worker_oprit(client):
"""Fara heartbeat -> worker oprit -> bifa rosie ✗ + text 'oprita'."""
_create_account_user("biferosu@test.com")
_login(client, "biferosu@test.com", "parolasecreta10")
_set_heartbeat(last_beat=None, last_rar_login_ok=None)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "&#10007;" in html, f"Lipseste glifa ✗ cand worker oprit. HTML: {html[:600]}"
assert "oprita" in html.lower()
def test_status_data_formatata_romaneste(client):
"""Ultima autentificare RAR apare ca dd.mm.yyyy hh24:mi:ss."""
_create_account_user("bifedata@test.com")
_login(client, "bifedata@test.com", "parolasecreta10")
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok="2026-06-18T14:30:22")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
assert "18.06.2026 14:30:22" in resp.text, (
f"Data nu e formatata romaneste. HTML: {resp.text[:800]}"
)
def test_status_fara_fonturi_minuscule(client):
"""Niciun text din bara nu mai foloseste font-size sub 13px (US-001 AC)."""
_create_account_user("bifefont@test.com")
_login(client, "bifefont@test.com", "parolasecreta10")
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
for bad in ("font-size:11px", "font-size:12px", "font-size: 11px", "font-size: 12px"):
assert bad not in html, f"Bara de status foloseste {bad} (sub 13px)."

View File

@@ -0,0 +1,150 @@
"""Teste US-004 (PRD 3.5): "Coada" -> "Trimiteri" tabel lizibil + detaliu la click.
Coloane umane (RO), stare via labels (nu "sent" brut), vehicul/operatie/data din
payload, motiv uman. Detaliu scoped pe cont (404 cross-account).
"""
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,
rar_error: str | None = None, id_prezentare=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, rar_error, id_prezentare) "
"VALUES (?, ?, ?, ?, ?, ?)",
(f"k-{status}-{os.urandom(4).hex()}", acct, status, json.dumps(p), rar_error, id_prezentare),
)
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_submissions_coloane_umane(client):
"""Antete RO; stare umana (nu 'sent'); vehicul/operatie din payload; fara 'HTTP RAR' ca antet."""
acct = _create_account_user("col@test.com")
_insert_submission(acct, "sent", id_prezentare=68516)
_login(client, "col@test.com")
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"):
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
# Starea afisata e text uman, nu 'sent' brut intr-un pill
assert ">sent<" not in html, "Starea bruta 'sent' nu ar trebui afisata"
assert "Declarate la RAR" in html, "Starea umana lipseste"
# Vehicul + operatie din payload, nu doar idPrezentare
assert "B777ZZZ" in html
assert "Reparatie frane" in html
def test_tab_eticheta_trimiteri(client):
"""Eticheta tab e 'Trimiteri' dar deep-link ?tab=coada ramane valid."""
_create_account_user("et@test.com")
_login(client, "et@test.com")
resp = client.get("/?tab=coada")
assert resp.status_code == 200
assert "Trimiteri" in resp.text
assert 'id="tab-coada"' in resp.text
def test_motiv_needs_data_afisat(client):
"""Pentru needs_data, coloana Motiv arata motivul (nu gol cand exista rar_error)."""
acct = _create_account_user("motiv@test.com")
_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")
assert resp.status_code == 200
assert "lipsa odometru" in resp.text
def test_detaliu_trimitere(client):
"""/_fragments/trimitere/{id} intoarce detaliul complet scoped pe cont."""
acct = _create_account_user("det@test.com")
sid = _insert_submission(acct, "sent", id_prezentare=99001)
_login(client, "det@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
assert f"Detaliu trimitere #{sid}" in html
assert "WVWZZZ1JZXW000777" in html # VIN integral in detaliu
assert "99001" in html # nr prezentare RAR
def test_detaliu_trimitere_404_cross_account(client):
"""Detaliul altui cont -> 404 (fara leak)."""
acct1 = _create_account_user("d1@test.com", name="C1")
_create_account_user("d2@test.com", name="C2")
sid1 = _insert_submission(acct1, "sent")
_login(client, "d2@test.com")
resp = client.get(f"/_fragments/trimitere/{sid1}")
assert resp.status_code == 404
# acelasi 404 pentru un id inexistent
resp2 = client.get("/_fragments/trimitere/999999")
assert resp2.status_code == 404

View File

@@ -83,9 +83,12 @@ def test_dashboard_are_tabbar(client):
html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist"
# Cele 6 tab-uri trebuie sa fie prezente
for label in ("Acasa", "Import", "Coada", "Mapari", "Cont", "Nomenclator"):
# Tab-urile trebuie sa fie prezente (Import a fuzionat in Acasa; "Coada"->"Trimiteri" — PRD 3.5)
for label in ("Acasa", "Trimiteri", "Mapari", "Cont", "Nomenclator"):
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
# "Import" nu mai e un tab separat in tab-bar (importul e direct pe Acasa)
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', html), \
"Tab-ul 'Import' nu ar mai trebui sa existe ca tab separat (US-002)"
# ============================================================