feat(web): dashboard ergonomic cu tab-uri, stepper import si microcopy uman (3.4)
Reorganizeaza interfata web pe trei principii, fara a atinge backend-ul de trimitere (worker, mapping, idempotency, masina de stari neatinse): - US-001 app/web/labels.py: modul pur stari tehnice -> text uman + clasa CSS - US-002 bara status /_fragments/status: microcopy uman, defalcare blocate, scoped cont - US-003 shell 6 tab-uri (Acasa/Import/Coada/Mapari/Cont/Nomenclator): deep-link ?tab=, panou activ randat server-side, fragmente inactive lazy, ARIA real - US-004 stepper import 4 pasi (pur vizual; hx-target + csrf pastrate) - US-005 Acasa onboarding checklist auto-bifat + colaps + empty states prietenoase Reparat in cursul VERIFY/CLOSE: izolare teste (reset ratelimit._hits in fixturi), regresie avertisment "cont in asteptare de activare" (re-introdus in bara status), culori hardcodate -> variabile paleta. 434 teste pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,9 +47,17 @@ def _body(**over):
|
||||
def test_dashboard_renders_with_rar_state(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
# worker neavand heartbeat -> stare RAR necunoscuta (worker oprit)
|
||||
assert "worker oprit" in r.text
|
||||
assert "Nomenclator RAR" in r.text
|
||||
# Dupa US-003 bara de status e incarcata via HTMX (hx-trigger=load, every 15s)
|
||||
assert "/_fragments/status" in r.text, "Dashboard-ul trebuie sa referenceze fragmentul de status"
|
||||
# Fragmentul de status contine starea worker (eticheta umana, nu "worker oprit" brut)
|
||||
rs = client.get("/_fragments/status")
|
||||
assert rs.status_code == 200
|
||||
# eticheta_worker(False) => "Trimitere automata: oprita" → fragmentul afiseaza "oprita"
|
||||
assert "oprita" in rs.text or "Trimitere automata" in rs.text
|
||||
# Tab-ul Nomenclator e accesat via /_fragments/nomenclator
|
||||
rn = client.get("/_fragments/nomenclator")
|
||||
assert rn.status_code == 200
|
||||
assert "Nomenclator" in rn.text or "Cod" in rn.text or "OE-1" in rn.text
|
||||
|
||||
|
||||
def test_nomenclator_fragment_lists_seed(client):
|
||||
@@ -63,7 +71,8 @@ def test_nomenclator_fragment_lists_seed(client):
|
||||
def test_submissions_fragment_empty_state(client):
|
||||
r = client.get("/_fragments/submissions")
|
||||
assert r.status_code == 200
|
||||
assert "Coada e goala" in r.text
|
||||
# US-005: empty state prietenos cu indemn la Import (nu mesajul tehnic vechi)
|
||||
assert "Nicio trimitere" in r.text or "incepe cu Import" in r.text or "?tab=import" in r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -102,8 +102,11 @@ def _seed_op_mapping(client, cod_op: str = "Revizie", cod_prest: str = "OE-1") -
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_dashboard_contine_drop_zone(client):
|
||||
"""Dashboard-ul randeaza sectiunea de upload cu drop zone si mesaj warmth."""
|
||||
r = client.get("/")
|
||||
"""Tab-ul Import randeaza sectiunea de upload cu drop zone si mesaj warmth.
|
||||
|
||||
Dupa US-003 sectiunea de import e in tab-ul Import (?tab=import), nu pe pagina principala.
|
||||
"""
|
||||
r = client.get("/?tab=import")
|
||||
assert r.status_code == 200
|
||||
assert "Primul fisier" in r.text
|
||||
assert "drop-zone" in r.text
|
||||
|
||||
293
tests/test_web_import_stepper.py
Normal file
293
tests/test_web_import_stepper.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Teste US-004: wizard import cu stepper vizual (4 pasi numerotati).
|
||||
|
||||
TDD — testele sunt scrise INAINTE de implementare (RED), apoi se face GREEN.
|
||||
|
||||
Verifica:
|
||||
- Pasul 1 activ (aria-current="step") in fragmentul de upload
|
||||
- Pasul 2 activ in fragmentul mapare-coloane
|
||||
- Pasul 3 activ in preview
|
||||
- Pasii 1 si 2 marcati ca "facuti" in preview (clasa/marcaj)
|
||||
- hx-target="#import-section" pastrat in fragmentele de import
|
||||
- csrf_token prezent in formularele de import
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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, "t.db"))
|
||||
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app.web import ratelimit
|
||||
ratelimit._hits.clear() # izolare: limiterul login e global in-proces
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpere
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
|
||||
import csv
|
||||
|
||||
buf = io.StringIO()
|
||||
if not rows:
|
||||
return b""
|
||||
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _make_xlsx_bytes(rows: list[dict]) -> bytes:
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
if not rows:
|
||||
return b""
|
||||
headers = list(rows[0].keys())
|
||||
ws.append(headers)
|
||||
for row in rows:
|
||||
ws.append([row.get(h) for h in headers])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
_SAMPLE_ROWS = [
|
||||
{
|
||||
"VIN": "WVWZZZ1KZAW000123",
|
||||
"Nr inmatriculare": "B001TST",
|
||||
"Data prestatie": "15.06.2026",
|
||||
"Odometru final": "123456",
|
||||
"Operatie": "Revizie",
|
||||
},
|
||||
{
|
||||
"VIN": "WVWZZZ1KZAW000456",
|
||||
"Nr inmatriculare": "B002TST",
|
||||
"Data prestatie": "16.06.2026",
|
||||
"Odometru final": "200000",
|
||||
"Operatie": "Revizie",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed_op_mapping(client, cod_op: str = "Revizie", cod_prest: str = "OE-1") -> None:
|
||||
client.post("/v1/mapari", json={
|
||||
"cod_op_service": cod_op,
|
||||
"cod_prestatie": cod_prest,
|
||||
"auto_send": True,
|
||||
})
|
||||
|
||||
|
||||
def _upload_and_get_import_id(client, rows=None) -> int:
|
||||
xlsx = _make_xlsx_bytes(rows or _SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.xlsx", xlsx,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
|
||||
assert m, f"Nu s-a gasit import_id in raspuns: {r.text[:500]}"
|
||||
return int(m.group(1))
|
||||
|
||||
|
||||
def _get_preview_via_mapare(client, import_id: int) -> str:
|
||||
"""Salveaza maparea de coloane si returneaza textul raspunsului 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
|
||||
return r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US-004 Teste stepper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stepper_pas1_la_upload(client):
|
||||
"""Fragmentul de upload contine stepper-ul cu pasul 1 activ.
|
||||
|
||||
Verifica prezenta marcajului aria-current='step' pe pasul 'Incarca fisier'
|
||||
sau clasa activa asociata pasului 1.
|
||||
"""
|
||||
r = client.get("/_import/reset")
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
# Stepper-ul trebuie sa fie prezent
|
||||
assert "stepper" in text or "pasi-import" in text or "step" in text.lower(), \
|
||||
"Stepper-ul nu a fost gasit in fragmentul de upload"
|
||||
# Pasul 1 trebuie sa aiba aria-current="step"
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in fragmentul de upload (pasul 1)"
|
||||
# Textul pasului 1 trebuie sa fie prezent
|
||||
assert "Incarca" in text, "Textul pasului 1 'Incarca' nu a fost gasit"
|
||||
|
||||
|
||||
def test_stepper_pas1_via_tab_import(client):
|
||||
"""Accesand /?tab=import, panoul contine stepper cu pasul 1 activ."""
|
||||
r = client.get("/?tab=import")
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in panoul Import (/?tab=import)"
|
||||
assert "Incarca" in text, "Textul pasului 1 'Incarca' nu a fost gasit in panoul Import"
|
||||
|
||||
|
||||
def test_stepper_pas2_la_mapare(client):
|
||||
"""Fragmentul mapare-coloane contine stepper cu pasul 2 activ.
|
||||
|
||||
Declanseaza un upload cu coloane NEMAPATE ca sa primesti _mapcoloane.html.
|
||||
"""
|
||||
# Upload fara mapare salvata → trebuie sa vina _mapcoloane.html
|
||||
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.csv", csv_bytes, "text/csv")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
# Trebuie sa fie formularul de mapare coloane
|
||||
assert "mapare-coloane" in text, "Nu s-a primit fragmentul de mapare coloane"
|
||||
# Stepper prezent
|
||||
assert "stepper" in text or "step" in text.lower(), \
|
||||
"Stepper-ul nu a fost gasit in fragmentul mapare-coloane"
|
||||
# Pasul 2 trebuie sa aiba aria-current="step" cu textul "Potriveste"
|
||||
# (pasul 1 e facut, pasul 2 e activ)
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in fragmentul mapare-coloane (pasul 2)"
|
||||
assert "Potriveste" in text, "Textul pasului 2 'Potriveste' nu a fost gasit"
|
||||
|
||||
|
||||
def test_stepper_pas3_la_preview(client):
|
||||
"""Preview contine stepper cu pasul 3 activ.
|
||||
|
||||
Declanseaza upload + salvare mapare → se ajunge la preview.
|
||||
"""
|
||||
_seed_op_mapping(client)
|
||||
import_id = _upload_and_get_import_id(client)
|
||||
text = _get_preview_via_mapare(client, import_id)
|
||||
|
||||
# Preview trebuie sa fie prezent
|
||||
assert "Preview" in text or "confirm-form" in text, \
|
||||
"Nu s-a primit fragmentul de preview"
|
||||
# Stepper prezent
|
||||
assert "stepper" in text or "step" in text.lower(), \
|
||||
"Stepper-ul nu a fost gasit in preview"
|
||||
# Pasul 3 activ
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in preview (pasul 3)"
|
||||
assert "Verifica" in text, "Textul pasului 3 'Verifica' nu a fost gasit in preview"
|
||||
|
||||
|
||||
def test_stepper_pas3_la_preview_direct_mapare_retinuta(client):
|
||||
"""Upload cu mapare retinuta sare direct la preview cu pasul 3 activ.
|
||||
|
||||
Primul upload + mapare memoreaza configuratia.
|
||||
Al doilea upload cu acelasi antet sare direct la preview (pas 3).
|
||||
Pasii 1 si 2 sunt implicit facuti (comportament stepper la pas=3).
|
||||
"""
|
||||
_seed_op_mapping(client)
|
||||
import_id1 = _upload_and_get_import_id(client)
|
||||
_get_preview_via_mapare(client, import_id1)
|
||||
|
||||
# Al doilea upload — mapare retinuta → preview direct
|
||||
xlsx = _make_xlsx_bytes(_SAMPLE_ROWS)
|
||||
r = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test2.xlsx", xlsx,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
# Preview direct cu mesaj "Mapare retinuta"
|
||||
assert "Mapare retinuta" in text, "Preview direct (mapare retinuta) nu a fost randat"
|
||||
# Stepper prezent cu pasul 3 activ
|
||||
assert 'aria-current="step"' in text, \
|
||||
"aria-current='step' nu a fost gasit in preview direct (mapare retinuta)"
|
||||
assert "Verifica" in text, "Textul pasului 3 'Verifica' nu a fost gasit in preview direct"
|
||||
|
||||
|
||||
def test_stepper_marcheaza_pasii_facuti(client):
|
||||
"""In preview (pas 3), pasii 1 si 2 sunt marcati ca facuti (clasa 'facut').
|
||||
|
||||
Verifica prin prezenta clasei CSS sau a marcajului vizual de 'facut'.
|
||||
"""
|
||||
_seed_op_mapping(client)
|
||||
import_id = _upload_and_get_import_id(client)
|
||||
text = _get_preview_via_mapare(client, import_id)
|
||||
|
||||
# Clasa "facut" trebuie sa apara pentru pasii 1 si 2 (index < pas curent)
|
||||
assert "facut" in text, \
|
||||
"Clasa/marcajul 'facut' nu a fost gasit in preview (pasii 1 si 2 ar trebui marcati ca facuti)"
|
||||
# Numarul de aparitii: cel putin 2 pasi marcati ca facuti
|
||||
count_facut = text.count("facut")
|
||||
assert count_facut >= 2, \
|
||||
f"Asteptat cel putin 2 pasi marcati ca 'facut' in preview, gasit {count_facut}"
|
||||
|
||||
|
||||
def test_import_hx_target_in_tab(client):
|
||||
"""Fragmentele de import pastreaza hx-target='#import-section'.
|
||||
|
||||
Fragmentul de upload (/_import/reset) trebuie sa contina
|
||||
hx-target='#import-section' pentru ca HTMX sa actualizeze corect
|
||||
containerul din panoul de tab, nu din alta parte.
|
||||
"""
|
||||
r = client.get("/_import/reset")
|
||||
assert r.status_code == 200
|
||||
text = r.text
|
||||
assert 'hx-target="#import-section"' in text, \
|
||||
"hx-target='#import-section' nu a fost gasit in fragmentul de upload"
|
||||
# Wrapper-ul extern trebuie sa aiba id="import-section"
|
||||
assert 'id="import-section"' in text, \
|
||||
"id='import-section' nu a fost gasit in fragmentul de upload"
|
||||
|
||||
|
||||
def test_import_forms_pastreaza_csrf(client):
|
||||
"""Formularele de import contin csrf_token (input hidden cu valoare).
|
||||
|
||||
Testeaza atat fragmentul de upload cat si cel de mapare coloane.
|
||||
"""
|
||||
# Fragment upload
|
||||
r_upload = client.get("/_import/reset")
|
||||
assert r_upload.status_code == 200
|
||||
text_upload = r_upload.text
|
||||
# Trebuie sa contina campul csrf_token (poate fi gol in modul dev fara sesiune,
|
||||
# dar campul trebuie sa existe)
|
||||
assert 'name="csrf_token"' in text_upload, \
|
||||
"name='csrf_token' nu a fost gasit in formularul de upload"
|
||||
|
||||
# Fragment mapare coloane
|
||||
csv_bytes = _make_csv_bytes(_SAMPLE_ROWS)
|
||||
r_map = client.post(
|
||||
"/_import/upload",
|
||||
files={"file": ("test.csv", csv_bytes, "text/csv")},
|
||||
)
|
||||
assert r_map.status_code == 200
|
||||
text_map = r_map.text
|
||||
if "mapare-coloane" in text_map: # s-a primit fragmentul de mapare
|
||||
assert 'name="csrf_token"' in text_map, \
|
||||
"name='csrf_token' nu a fost gasit in formularul mapare-coloane"
|
||||
145
tests/test_web_labels.py
Normal file
145
tests/test_web_labels.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Teste pentru app/web/labels.py — modul de etichete umane (US-001, PRD 3.4).
|
||||
|
||||
Ordinea: RED (scrise inainte de implementare), apoi GREEN dupa creare labels.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utilitara: extrage starile din CHECK constraint in schema.sql
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _starile_din_schema() -> list[str]:
|
||||
"""
|
||||
Parseaza `app/schema.sql` si returneaza lista starilor din CHECK constraint
|
||||
al coloanei `status` din tabela `submissions`.
|
||||
|
||||
Linia relevanta (schema.sql, tabela submissions):
|
||||
CHECK (status IN ('queued','sending','sent','needs_mapping','needs_data','error'))
|
||||
|
||||
Testul devine automat RED daca cineva adauga o stare noua in schema
|
||||
fara s-o mapeze in labels.py.
|
||||
"""
|
||||
schema_path = Path(__file__).parent.parent / "app" / "schema.sql"
|
||||
sql = schema_path.read_text(encoding="utf-8")
|
||||
|
||||
# Cauta blocul CHECK aferent coloanei status din CREATE TABLE submissions.
|
||||
# Pattern: CHECK (status IN ('a','b',...)) pe una sau mai multe linii.
|
||||
match = re.search(
|
||||
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
|
||||
sql,
|
||||
)
|
||||
assert match, "Nu am gasit CHECK (status IN (...)) in schema.sql — schema s-a schimbat?"
|
||||
|
||||
raw = match.group(1)
|
||||
# Extrage valorile dintre ghilimele simple
|
||||
stari = re.findall(r"'([^']+)'", raw)
|
||||
assert stari, "Lista de stari din CHECK este goala — ceva s-a stricat la parsare."
|
||||
return stari
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import modulul de etichete (va esua la RED, inainte de implementare)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.web.labels import eticheta_stare, eticheta_worker, eticheta_rar # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste worker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eticheta_worker_viu():
|
||||
text, subtext, css_class = eticheta_worker(viu=True)
|
||||
assert "Trimitere automata" in text, (
|
||||
f"Textul pentru worker viu trebuie sa contina 'Trimitere automata', got: {text!r}"
|
||||
)
|
||||
assert "activa" in text.lower() or "activa" in subtext.lower(), (
|
||||
f"Starea 'activa' trebuie sa apara in text sau subtext, got: text={text!r}, subtext={subtext!r}"
|
||||
)
|
||||
# Nu trebuie sa afiseze cuvintele tehnice brute
|
||||
assert "viu" not in text.lower(), f"Textul nu trebuie sa contina 'viu': {text!r}"
|
||||
# Clasa CSS trebuie sa fie definita (non-vida)
|
||||
assert css_class, "css_class nu trebuie sa fie vida pentru worker viu"
|
||||
|
||||
|
||||
def test_eticheta_worker_mort():
|
||||
text, subtext, css_class = eticheta_worker(viu=False)
|
||||
assert "Trimitere automata" in text, (
|
||||
f"Textul pentru worker mort trebuie sa contina 'Trimitere automata', got: {text!r}"
|
||||
)
|
||||
assert "oprita" in text.lower() or "oprita" in subtext.lower(), (
|
||||
f"Starea 'oprita' trebuie sa apara in text sau subtext, got: text={text!r}, subtext={subtext!r}"
|
||||
)
|
||||
assert "mort" not in text.lower(), f"Textul nu trebuie sa contina 'mort': {text!r}"
|
||||
assert css_class, "css_class nu trebuie sa fie vida pentru worker mort"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teste eticheta_rar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eticheta_rar_ok():
|
||||
text, subtext, css_class = eticheta_rar(stare="ok")
|
||||
assert "Legatura cu RAR" in text, f"got: {text!r}"
|
||||
assert "functionala" in text.lower() or "functionala" in subtext.lower()
|
||||
assert css_class
|
||||
|
||||
|
||||
def test_eticheta_rar_indisponibil():
|
||||
text, subtext, css_class = eticheta_rar(stare="indisponibil")
|
||||
assert "Legatura cu RAR" in text, f"got: {text!r}"
|
||||
assert "indisponibila" in text.lower() or "indisponibila" in subtext.lower()
|
||||
assert css_class
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test eticheta_stare pentru fiecare stare de submission
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_eticheta_stare_submission():
|
||||
"""Verifica textele umane concrete pentru fiecare stare cunoscuta."""
|
||||
cazuri = {
|
||||
"queued": ("In asteptare", "s-queued"),
|
||||
"sent": ("Declarate la RAR", "s-sent"),
|
||||
"sending": ("Se trimite", "s-sending"),
|
||||
"needs_mapping": ("Lipseste codul", "s-needs_mapping"),
|
||||
"needs_data": ("Date incomplete", "s-needs_data"),
|
||||
"error": ("Eroare", "s-error"),
|
||||
}
|
||||
for status, (fragment_text, clasa) in cazuri.items():
|
||||
text, subtext, css_class = eticheta_stare(status)
|
||||
assert fragment_text.lower() in text.lower(), (
|
||||
f"Status {status!r}: asteptam '{fragment_text}' in text, got {text!r}"
|
||||
)
|
||||
assert css_class == clasa, (
|
||||
f"Status {status!r}: asteptam css_class={clasa!r}, got {css_class!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test parametrizat: TOATE starile din schema au eticheta (anti-drift)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STARI_SCHEMA = _starile_din_schema()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", _STARI_SCHEMA)
|
||||
def test_toate_starile_au_eticheta(status: str):
|
||||
"""
|
||||
Fiecare stare din CHECK constraint (schema.sql) trebuie sa aiba o eticheta
|
||||
non-vida si o clasa CSS non-vida in labels.py.
|
||||
|
||||
Daca cineva adauga o stare noua in schema fara s-o mapeze, acest test devine RED.
|
||||
"""
|
||||
text, subtext, css_class = eticheta_stare(status)
|
||||
assert text, f"Status {status!r}: textul etichetei este vid."
|
||||
assert css_class, f"Status {status!r}: clasa CSS este vida."
|
||||
# Textul nu trebuie sa fie chiar statusul tehnic brut (ex. "queued" afisat ca atare)
|
||||
assert text.lower() != status.lower(), (
|
||||
f"Status {status!r}: eticheta umana este identica cu statusul tehnic — nu e o eticheta umana."
|
||||
)
|
||||
260
tests/test_web_onboarding.py
Normal file
260
tests/test_web_onboarding.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Teste US-005 (PRD 3.4): pagina Acasa cu ghid de pornire (checklist auto-bifat).
|
||||
|
||||
TDD: testele sunt scrise INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Rute testate:
|
||||
- GET / (tab Acasa) -> ghid cu pasi bifati/nebifati in functie de starea contului
|
||||
- GET /_fragments/acasa -> fragment HTMX pentru tab-ul Acasa
|
||||
- GET /_fragments/submissions -> empty state prietenos cand coada e goala
|
||||
- GET /_fragments/mapari -> empty state prietenos cand nu sunt mapari pendinte
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helpers
|
||||
# ============================================================
|
||||
|
||||
def _create_account_user(email: str, password: str = "parolasecreta10"):
|
||||
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
|
||||
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, f"Service Test {email}", 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 = "parolasecreta10") -> None:
|
||||
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
|
||||
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"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
def _set_rar_creds(acct_id: int) -> None:
|
||||
"""Seteaza rar_creds_enc pe cont (simuleaza configurarea credentialelor RAR)."""
|
||||
from app.db import get_connection
|
||||
from app.crypto import encrypt_creds
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
enc = encrypt_creds({"email": "test@rar.ro", "password": "parola_rar"})
|
||||
conn.execute(
|
||||
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
|
||||
(enc, acct_id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _add_submission(acct_id: int) -> None:
|
||||
"""Adauga un submission minimal pentru cont (simuleaza un import efectuat)."""
|
||||
import json
|
||||
from app.db import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, 'queued', ?)",
|
||||
(f"test_key_{acct_id}", acct_id, json.dumps({"test": True})),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixture
|
||||
# ============================================================
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client cu BD izolata si autentificare web activata."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "onboarding_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() # izolare: limiterul login e global in-proces
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_checklist_pas_creds_neconfigurat
|
||||
# ============================================================
|
||||
|
||||
def test_checklist_pas_creds_neconfigurat(client):
|
||||
"""Cont fara creds RAR -> pasul 'Conecteaza contul RAR' e NEbifat."""
|
||||
acct_id, _ = _create_account_user("nocreds@test.com")
|
||||
_login(client, "nocreds@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Pasul de conectare RAR trebuie sa apara
|
||||
assert "Conecteaza" in html or "cont RAR" in html or "RAR" in html, \
|
||||
"Ghidul nu contine referinta la conectarea contului RAR"
|
||||
|
||||
# Cand nu sunt creds, pasul NU trebuie sa fie bifat
|
||||
# Bifarea e semnalata printr-o clasa 'bifat' sau o checkmark langa text-ul RAR
|
||||
# Verificam ca nu apare combinatia "bifat" + "RAR" sau "done" + "RAR" in proximitate
|
||||
# (implementarea exacta e in template, dar pattern-ul de baza: fara `pas-bifat` langa RAR)
|
||||
assert not re.search(
|
||||
r'pas-bifat[^<]*Conecteaza|Conecteaza[^<]*pas-bifat',
|
||||
html, re.DOTALL | re.IGNORECASE
|
||||
), "Pasul RAR nu trebuie sa fie bifat cand contul nu are creds"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_checklist_pas_creds_bifat_cand_exista
|
||||
# ============================================================
|
||||
|
||||
def test_checklist_pas_creds_bifat_cand_exista(client):
|
||||
"""Dupa setarea rar_creds_enc pe cont -> pasul 'Conecteaza contul RAR' e bifat."""
|
||||
acct_id, _ = _create_account_user("withcreds@test.com")
|
||||
_set_rar_creds(acct_id)
|
||||
_login(client, "withcreds@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
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"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_checklist_ascuns_cand_totul_gata
|
||||
# ============================================================
|
||||
|
||||
def test_checklist_ascuns_cand_totul_gata(client):
|
||||
"""Creds setate + cel putin un submission -> ghidul se colapseaza/devine discret."""
|
||||
acct_id, _ = _create_account_user("allset@test.com")
|
||||
_set_rar_creds(acct_id)
|
||||
_add_submission(acct_id)
|
||||
_login(client, "allset@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
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"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_linkuri_ghid_duc_la_taburi
|
||||
# ============================================================
|
||||
|
||||
def test_linkuri_ghid_duc_la_taburi(client):
|
||||
"""Link-urile din ghid contin ?tab=cont si ?tab=import."""
|
||||
acct_id, _ = _create_account_user("links@test.com")
|
||||
_login(client, "links@test.com")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Ghidul trebuie sa contina link catre tab-ul Cont
|
||||
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)"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_empty_state_coada_gol
|
||||
# ============================================================
|
||||
|
||||
def test_empty_state_coada_gol(client):
|
||||
"""Tab Coada fara submissions -> indemn prietenos catre Import, nu mesaj tehnic."""
|
||||
acct_id, _ = _create_account_user("emptyq@test.com")
|
||||
_login(client, "emptyq@test.com")
|
||||
|
||||
resp = client.get("/_fragments/submissions")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Nu trebuie sa apara mesajul tehnic vechi cu POST /v1/prezentari
|
||||
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
|
||||
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"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_empty_state_mapari_gol
|
||||
# ============================================================
|
||||
|
||||
def test_empty_state_mapari_gol(client):
|
||||
"""Tab Mapari fara pending -> mesaj prietenos cu indemn (nu lista goala fara context)."""
|
||||
acct_id, _ = _create_account_user("emptym@test.com")
|
||||
_login(client, "emptym@test.com")
|
||||
|
||||
resp = client.get("/_fragments/mapari")
|
||||
assert resp.status_code == 200
|
||||
html = resp.text
|
||||
|
||||
# Trebuie sa apara un mesaj prietenos cand nu sunt mapari pendinte
|
||||
# Nu verificam exact textul, dar trebuie sa existe un indemn/explicatie
|
||||
assert "Nicio operatie nemapata" in html or "totul" in html.lower() or "import" in html.lower(), \
|
||||
"Empty state mapari trebuie sa contina mesaj prietenos"
|
||||
|
||||
# Trebuie sa contina un indemn catre Import sau o explicatie clara
|
||||
# (cel putin link catre import sau mentionarea cuvantului)
|
||||
assert "import" in html.lower() or "?tab=import" in html, \
|
||||
"Empty state mapari trebuie sa contina indemn catre Import"
|
||||
187
tests/test_web_status_fragment.py
Normal file
187
tests/test_web_status_fragment.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Teste US-002 (PRD 3.4): bara de status persistenta cu etichete umane.
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Rute testate:
|
||||
- GET /_fragments/status -> bara de status cu etichete umane, scoped pe cont
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str = "user@test.com", password: str = "parolasecreta10"):
|
||||
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
|
||||
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 Status", 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:
|
||||
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
|
||||
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"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
def _insert_submission(status: str, account_id: int) -> None:
|
||||
"""Insereaza un submission cu status dat pentru un cont dat."""
|
||||
from app.db import get_connection
|
||||
import json
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
f"test-key-{status}-{account_id}-{os.urandom(4).hex()}",
|
||||
account_id,
|
||||
status,
|
||||
json.dumps({"vin": "TEST", "status": status}),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client cu BD izolata si autentificare web activata."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "status_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() # izolare: limiterul login e global in-proces
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_status_fragment_text_uman
|
||||
# ============================================================
|
||||
|
||||
def test_status_fragment_text_uman(client):
|
||||
"""GET /_fragments/status (autentificat) -> contine 'Trimitere automata', NU 'worker viu'."""
|
||||
_create_account_user("status@test.com", "parolasecreta10")
|
||||
_login(client, "status@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa contina textul uman din eticheta_worker (labels.py)
|
||||
assert "Trimitere automata" in html, (
|
||||
f"Fragmentul nu contine 'Trimitere automata'. HTML (primele 500 ch): {html[:500]}"
|
||||
)
|
||||
# NU trebuie sa contina textul brut tehnic
|
||||
assert "worker viu" not in html.lower(), (
|
||||
f"Fragmentul contine 'worker viu' (text tehnic brut). HTML (primele 500 ch): {html[:500]}"
|
||||
)
|
||||
# NU trebuie sa contina "mort" (stare tehnica bruta)
|
||||
# (poate aparea in 'oprita' -> acceptam; 'mort' singur -> nu)
|
||||
# Verificam ca nu apare 'mort' ca eticheta standalone
|
||||
assert "viu</div>" not in html, (
|
||||
"Fragmentul contine eticheta bruta 'viu'"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_status_blocate_defalcare
|
||||
# ============================================================
|
||||
|
||||
def test_status_blocate_defalcare(client):
|
||||
"""Cu submissions blocate in DB, fragmentul arata defalcarea pe motiv (texte umane)."""
|
||||
acct_id, _ = _create_account_user("blocate@test.com", "parolasecreta10")
|
||||
_login(client, "blocate@test.com", "parolasecreta10")
|
||||
|
||||
# Insereaza submissions blocate din fiecare tip
|
||||
_insert_submission("needs_mapping", acct_id)
|
||||
_insert_submission("needs_mapping", acct_id)
|
||||
_insert_submission("needs_data", acct_id)
|
||||
_insert_submission("error", acct_id)
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa arate titlul grupului de blocate
|
||||
assert "Necesita atentia ta" in html, (
|
||||
f"Fragmentul nu contine 'Necesita atentia ta'. HTML: {html[:800]}"
|
||||
)
|
||||
# Trebuie sa arate etichetele umane pe motiv (din STARI_SUBMISSION in labels.py)
|
||||
assert "Lipseste codul prestatiei" in html, (
|
||||
"Fragmentul nu arata eticheta pentru needs_mapping"
|
||||
)
|
||||
assert "Date incomplete" in html, (
|
||||
"Fragmentul nu arata eticheta pentru needs_data"
|
||||
)
|
||||
assert "Eroare la trimitere" in html, (
|
||||
"Fragmentul nu arata eticheta pentru error"
|
||||
)
|
||||
# Trebuie sa arate numere concrete (2 needs_mapping, 1 needs_data, 1 error)
|
||||
# Verificam ca exista cel putin un numar > 0 langa fiecare eticheta
|
||||
# (nu strict format, ci prezenta datelor)
|
||||
assert "2" in html or "1" in html, "Fragmentul nu arata numarul de submissions blocate"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_status_se_reincarca_htmx
|
||||
# ============================================================
|
||||
|
||||
def test_status_se_reincarca_htmx(client):
|
||||
"""Fragmentul contine atribut hx-trigger cu poll periodic (every 15s)."""
|
||||
_create_account_user("htmx@test.com", "parolasecreta10")
|
||||
_login(client, "htmx@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/_fragments/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Trebuie sa contina hx-trigger periodic
|
||||
assert "hx-trigger" in html, (
|
||||
f"Fragmentul nu contine atribut hx-trigger. HTML: {html[:500]}"
|
||||
)
|
||||
assert "every 15s" in html, (
|
||||
f"Fragmentul nu contine poll 'every 15s'. HTML: {html[:500]}"
|
||||
)
|
||||
# Trebuie sa aiba endpoint corect pentru auto-refresh
|
||||
assert "/_fragments/status" in html, (
|
||||
"Fragmentul nu contine referinta la /_fragments/status pentru hx-get"
|
||||
)
|
||||
# Trebuie sa aiba un id stabil pe containerul radacina
|
||||
assert 'id="status-bar"' in html, (
|
||||
"Fragmentul nu are id='status-bar' pe containerul radacina"
|
||||
)
|
||||
212
tests/test_web_tabs.py
Normal file
212
tests/test_web_tabs.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Teste US-003 (PRD 3.4): navigare cu tab-uri (shell dashboard).
|
||||
|
||||
TDD: testele se scriu INAINTE de implementare; la inceput pica (RED),
|
||||
dupa implementare trec (GREEN).
|
||||
|
||||
Rute testate:
|
||||
- GET / -> dashboard cu tab-bar si panou activ randat server-side
|
||||
- GET /?tab=<name> -> deep-link, panoul corespunzator randat server-side
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _create_account_user(email: str = "tabs@test.com", password: str = "parolasecreta10"):
|
||||
"""Creeaza cont + user. Intoarce (acct_id, user_id)."""
|
||||
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 Tabs", 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:
|
||||
"""Face login real prin HTTP si seteaza cookie-ul de sesiune pe client."""
|
||||
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"
|
||||
csrf = m.group(1)
|
||||
|
||||
resp = client.post("/login", data={
|
||||
"email": email,
|
||||
"parola": password,
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert resp.status_code == 303, f"Login esuat: {resp.status_code} {resp.text[:200]}"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""Client cu BD izolata si autentificare web activata."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "tabs_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() # izolare: limiterul login e global in-proces
|
||||
from app.main import app
|
||||
with TestClient(app, follow_redirects=False) as c:
|
||||
yield c
|
||||
ratelimit._hits.clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_dashboard_are_tabbar
|
||||
# ============================================================
|
||||
|
||||
def test_dashboard_are_tabbar(client):
|
||||
"""Dashboard-ul contine un tab-bar cu cele 6 tab-uri."""
|
||||
_create_account_user("tabbar@test.com", "parolasecreta10")
|
||||
_login(client, "tabbar@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
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"):
|
||||
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_tab_implicit_acasa
|
||||
# ============================================================
|
||||
|
||||
def test_tab_implicit_acasa(client):
|
||||
"""Fara ?tab=, tab-ul Acasa are aria-selected=true."""
|
||||
_create_account_user("implicit@test.com", "parolasecreta10")
|
||||
_login(client, "implicit@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Tab-ul activ trebuie sa aiba aria-selected="true"
|
||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
||||
|
||||
# Verificam ca Acasa e cel cu aria-selected=true
|
||||
# Cautam un fragment care contine atat Acasa cat si aria-selected="true" in proximitate
|
||||
assert re.search(r'aria-selected="true"[^>]*>.*?Acasa|Acasa.*?aria-selected="true"', html, re.DOTALL), \
|
||||
"Tab-ul Acasa nu are aria-selected=true"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_deeplink_tab_import
|
||||
# ============================================================
|
||||
|
||||
def test_deeplink_tab_import(client):
|
||||
"""/?tab=import randeaza panoul Import server-side la full load."""
|
||||
_create_account_user("deeplink@test.com", "parolasecreta10")
|
||||
_login(client, "deeplink@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/?tab=import")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Panoul Import trebuie sa contina id="import-section" (din _upload.html)
|
||||
assert 'id="import-section"' in html, (
|
||||
"Panoul Import nu contine id='import-section' la full load cu ?tab=import"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_tab_activ_randat_server_side
|
||||
# ============================================================
|
||||
|
||||
def test_tab_activ_randat_server_side(client):
|
||||
"""Panoul activ e in HTML-ul initial, nu doar cerut prin HTMX dupa load."""
|
||||
_create_account_user("serverside@test.com", "parolasecreta10")
|
||||
_login(client, "serverside@test.com", "parolasecreta10")
|
||||
|
||||
# Tab-ul implicit (Acasa) trebuie sa fie randat server-side
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Panoul trebuie sa aiba role="tabpanel"
|
||||
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel in HTML initial"
|
||||
|
||||
# Import tab server-side
|
||||
resp2 = client.get("/?tab=import")
|
||||
assert resp2.status_code == 200
|
||||
html2 = resp2.text
|
||||
# Continutul Import trebuie sa fie randat direct, nu prin hx-trigger=load pe panoul inactiv
|
||||
assert 'id="import-section"' in html2, "Panoul Import nu e randat server-side la ?tab=import"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_fragmentele_inactive_lazy
|
||||
# ============================================================
|
||||
|
||||
def test_fragmentele_inactive_lazy(client):
|
||||
"""Panourile inactive nu se cer la load — fara hx-trigger=load pe fragmentele inactive."""
|
||||
_create_account_user("lazy@test.com", "parolasecreta10")
|
||||
_login(client, "lazy@test.com", "parolasecreta10")
|
||||
|
||||
# La tab implicit (Acasa): panoul de submissions (Coada) NU trebuie sa fie in HTML
|
||||
# cu hx-trigger="load" (ar insemna ca se incarca automat la deschiderea paginii)
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
# Verificam ca nu exista un container de submissions cu hx-trigger care include "load"
|
||||
# cand Coada NU e tab-ul activ
|
||||
# Pattern: hx-get="/_fragments/submissions" ... hx-trigger="load..."
|
||||
# Aceasta combinatie NU trebuie sa apara cand tab-ul activ e Acasa
|
||||
submissions_load_pattern = re.search(
|
||||
r'hx-get="/_fragments/submissions"[^>]*hx-trigger="[^"]*load|'
|
||||
r'hx-trigger="[^"]*load[^"]*"[^>]*hx-get="/_fragments/submissions"',
|
||||
html
|
||||
)
|
||||
assert not submissions_load_pattern, (
|
||||
"Fragmentul de submissions (Coada) are hx-trigger=load cand tab-ul activ nu e Coada"
|
||||
)
|
||||
|
||||
# La ?tab=coada: panoul de submissions TREBUIE sa fie in HTML (randat server-side sau cu poll)
|
||||
resp2 = client.get("/?tab=coada")
|
||||
assert resp2.status_code == 200
|
||||
html2 = resp2.text
|
||||
# Cand Coada e activ, containerul de submissions trebuie sa existe
|
||||
assert "/_fragments/submissions" in html2 or "Coada submissions" in html2, (
|
||||
"Panoul Coada nu contine referinta la submissions cand e tab-ul activ"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# test_tabbar_aria
|
||||
# ============================================================
|
||||
|
||||
def test_tabbar_aria(client):
|
||||
"""Prezenta atributelor ARIA: role=tablist/tab/tabpanel, aria-selected."""
|
||||
_create_account_user("aria@test.com", "parolasecreta10")
|
||||
_login(client, "aria@test.com", "parolasecreta10")
|
||||
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.text
|
||||
assert 'role="tablist"' in html, "Lipseste role=tablist"
|
||||
assert 'role="tab"' in html, "Lipseste role=tab"
|
||||
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel"
|
||||
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
|
||||
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive"
|
||||
Reference in New Issue
Block a user