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:
@@ -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):
|
||||
|
||||
82
tests/test_payload_view.py
Normal file
82
tests/test_payload_view.py
Normal 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
123
tests/test_web_badge.py
Normal 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
205
tests/test_web_corectie.py
Normal 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
100
tests/test_web_dashboard.py
Normal 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
141
tests/test_web_filtrare.py
Normal 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
|
||||
145
tests/test_web_formate_coloane.py
Normal file
145
tests/test_web_formate_coloane.py
Normal 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)"
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
219
tests/test_web_mapari_salvate.py
Normal file
219
tests/test_web_mapari_salvate.py
Normal 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
116
tests/test_web_mapari_ui.py
Normal 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
|
||||
@@ -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 "✓" 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)"
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
88
tests/test_web_preview_motive.py
Normal file
88
tests/test_web_preview_motive.py
Normal 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
129
tests/test_web_status.py
Normal 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 "✓" 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 "✗" 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)."
|
||||
150
tests/test_web_submissions.py
Normal file
150
tests/test_web_submissions.py
Normal 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
|
||||
@@ -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)"
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user