feat(web): uniformizare/standardizare UI/UX + lifecycle conturi (PRD 5.5)

Aduce toate suprafetele dashboard-ului la grila tabelului Trimiteri, muta
navigarea intr-un meniu de cont (hamburger) si da panoului admin actiuni
reale de ciclu de viata. 9 stories, 3 valuri. UI pur (reskin + reasezare)
cu O SINGURA exceptie backend: modelul de stare a contului.

- US-001 sectiunea "Ajutor" eliminata din Acasa (wayfinding redundant).
- US-002 Nomenclator la grila standard (_submissions.html ca referinta).
- US-003 macro autosend compact (Manual<->Auto). Semantica de PREZENTA
  `auto_send` (bifat->true, absent->false) NEALTERATA — compatibil cu ambele
  parsere (Form(bool) la /mapari, bool(form.get()) la import). Zero backend.
- US-004 accounts.status (pending/active/blocked/archived/deleted), migrare
  defensiva idempotenta derivata din `active`, gate worker claim_one pe
  status='active' (echivalenta active=1 <=> status='active' pastrata).
- US-005 tabel Mapari compact + panou Ajutor (<details>, proza o singura data),
  coloana "In coada".
- US-006 meniu hamburger dropdown (Cont/Integrare/Nomenclator/Admin/logout) +
  context is_authenticated/is_admin/csrf_token defensiv in base.html.
- US-007 tab-bar redus la Acasa+Mapari; rutele /_fragments/{cont,integrare,
  nomenclator} + deep-link ?tab= raman valide.
- US-008 rute admin block/archive/delete + bulk pe lista account_id,
  require_admin + CSRF + PRG, dev id=1 sarit in bulk.
- US-009 admin UI: selectie bife + master + bara bulk + kebab per-rand,
  grupare pe stare (bloc nou blocate/arhivate), nota "cont dev implicit" scoasa.

Stergere = SOFT: tombstone (status='deleted'), dar PII purjata IMEDIAT
(rar_creds_enc + chei API revocate + CUI eliberat pentru re-inregistrare),
GDPR/L.142.

VERIFY: 671 teste pass (+40). E2E browser (Playwright) a prins 2 bug-uri
invizibile la TestClient: bara bulk cu display:flex inline invingea [hidden]
(mutat in CSS .bulk-bar[hidden]); conturi arhivate cadeau sub "in asteptare"
(grupare pe status). /code-review high a prins 2 bug-uri reale: soft delete
pastra creds RAR + CUI la nesfarsit fara purjare accounts (GDPR neonorat);
apostrof in numele firmei rupea confirm() inline din kebab — ambele reparate,
plus cleanup boilerplate rute (_lifecycle_route).

Backend trimitere (worker masina stari/idempotenta/mapping) neatins, cu
exceptia gate-ului de cont. Design: docs/design/5.5-uniformizare-ui.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-23 11:56:05 +00:00
parent 14e1c463f0
commit 1fbd894329
27 changed files with 1700 additions and 201 deletions

View File

@@ -102,12 +102,13 @@ def test_acasa_fara_linkuri_ajutor(client):
assert 'href="/?tab=coada"' not in r.text
def test_acasa_pastreaza_wayfinding_mapari_coduri(client):
"""Wayfinding-ul pastreaza 'Mapari' si 'Coduri RAR'."""
def test_acasa_fara_wayfinding_ajutor(client):
"""US-001 (5.5): randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
navigarea traieste in tab-bar si in meniul de cont."""
r = client.get("/?tab=acasa")
html = r.text
assert 'href="/?tab=mapari"' in html
assert "Coduri RAR" in html
assert "Ajutor:" not in html
assert "Coduri RAR" not in html
def test_badge_trimiteri_scoped_pe_acasa(client):

View File

@@ -0,0 +1,160 @@
"""Teste US-004 (PRD 5.5): model de stare a contului `accounts.status` + helperi.
Acopera: derivarea status din active la migrare, invariantul active=1 <=> status='active',
helperii set_status/delete_account, protectia contului de sistem id=1, excluderea 'deleted'
din listare.
"""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_status.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _status(conn, acct_id):
return conn.execute("SELECT status, active FROM accounts WHERE id=?", (acct_id,)).fetchone()
def test_create_account_activ_status_active(conn):
from app.accounts import create_account
acct_id = create_account(conn, "Service X", active=True)
row = _status(conn, acct_id)
assert row["status"] == "active" and row["active"] == 1
def test_create_account_inactiv_status_pending(conn):
from app.accounts import create_account
acct_id = create_account(conn, "Service Y", active=False)
row = _status(conn, acct_id)
assert row["status"] == "pending" and row["active"] == 0
def test_default_account_id1_active(conn):
row = _status(conn, 1)
assert row["status"] == "active" and row["active"] == 1
def test_set_status_mentine_invariant_active(conn):
from app.accounts import create_account, set_status
acct_id = create_account(conn, "Service Z", active=True)
for st, exp_active in [("blocked", 0), ("archived", 0), ("active", 1), ("pending", 0)]:
set_status(conn, acct_id, st)
row = _status(conn, acct_id)
assert row["status"] == st and row["active"] == exp_active
def test_set_status_invalid_ridica(conn):
from app.accounts import create_account, set_status
acct_id = create_account(conn, "Service W", active=True)
with pytest.raises(ValueError):
set_status(conn, acct_id, "inexistent")
def test_set_status_cont_inexistent_ridica(conn):
from app.accounts import set_status
with pytest.raises(ValueError):
set_status(conn, 9999, "blocked")
def test_set_active_mirror_status(conn):
from app.accounts import create_account, set_active
acct_id = create_account(conn, "Service M", active=True)
set_active(conn, acct_id, False)
assert _status(conn, acct_id)["status"] == "pending"
set_active(conn, acct_id, True)
assert _status(conn, acct_id)["status"] == "active"
def test_delete_account_soft(conn):
from app.accounts import create_account, delete_account, list_accounts
acct_id = create_account(conn, "Service D", active=True)
delete_account(conn, acct_id)
assert _status(conn, acct_id)["status"] == "deleted"
# exclus din listare
assert all(a["id"] != acct_id for a in list_accounts(conn))
def test_delete_purjeaza_pii_si_elibereaza_cui(conn):
"""Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil)."""
from app.accounts import create_account, delete_account, list_accounts
acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True)
conn.execute("UPDATE accounts SET rar_creds_enc='secret_enc' WHERE id=?", (acct_id,))
conn.execute("INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, 'h', 1)", (acct_id,))
conn.commit()
delete_account(conn, acct_id)
row = conn.execute("SELECT status, rar_creds_enc, cui FROM accounts WHERE id=?",
(acct_id,)).fetchone()
assert row["status"] == "deleted"
assert row["rar_creds_enc"] is None, "creds RAR trebuie purjate la stergere"
assert row["cui"] is None, "CUI trebuie eliberat la stergere"
key_active = conn.execute("SELECT active FROM api_keys WHERE account_id=?", (acct_id,)).fetchone()
assert key_active["active"] == 0, "cheile API trebuie revocate"
# CUI eliberat -> se poate re-inregistra acelasi CUI
new_id = create_account(conn, "Service Nou", cui="RO12345", active=True)
assert new_id != acct_id
assert all(a["id"] != acct_id for a in list_accounts(conn))
def test_dev_id1_protejat_de_status_negativ(conn):
from app.accounts import set_status, delete_account
for verb in ("blocked", "archived", "deleted"):
with pytest.raises(ValueError):
set_status(conn, 1, verb)
with pytest.raises(ValueError):
delete_account(conn, 1)
# ramane activ
assert _status(conn, 1)["status"] == "active"
def test_migrare_deriva_status_din_active(conn):
"""DB veche fara coloana status -> _migrate o adauga si o deriva din active.
Pornim de la schema reala (fixtura `conn` a rulat init_db), reconstruim tabela accounts
FARA coloana status (simuleaza DB pre-5.5), apoi rulam _migrate.
"""
from app.db import _migrate
# Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi).
conn.executescript(
"""
PRAGMA foreign_keys=OFF;
CREATE TABLE accounts_legacy (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cui TEXT,
active INTEGER NOT NULL DEFAULT 1, rar_creds_enc TEXT, created_at TEXT
);
INSERT INTO accounts_legacy (id, name, cui, active, rar_creds_enc, created_at)
SELECT id, name, cui, active, rar_creds_enc, created_at FROM accounts;
DROP TABLE accounts;
ALTER TABLE accounts_legacy RENAME TO accounts;
"""
)
conn.execute("INSERT INTO accounts (name, active) VALUES ('Activ', 1)")
conn.execute("INSERT INTO accounts (name, active) VALUES ('Inactiv', 0)")
conn.commit()
assert "status" not in {r["name"] for r in conn.execute("PRAGMA table_info(accounts)")}
_migrate(conn)
conn.commit()
rows = {r["name"]: r["status"] for r in conn.execute("SELECT name, status FROM accounts")}
assert rows["default"] == "active" # id=1
assert rows["Activ"] == "active"
assert rows["Inactiv"] == "pending"

View File

@@ -112,4 +112,4 @@ def test_list_accounts_ordonat_fara_creds(conn):
assert ids == sorted(ids)
for r in rows:
assert "rar_creds_enc" not in r
assert set(r.keys()) == {"id", "name", "cui", "active", "created_at"}
assert set(r.keys()) == {"id", "name", "cui", "active", "status", "created_at"}

View File

@@ -0,0 +1,137 @@
"""Teste US-008 (PRD 5.5): rute admin pentru ciclul de viata al conturilor —
block/archive/delete + bulk pe lista account_id, require_admin + CSRF + PRG, dev id=1 protejat.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_lifecycle.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
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
get_settings.cache_clear()
def _csrf(client, url="/admin"):
resp = client.get(url, follow_redirects=True)
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, f"csrf negasit in {url}"
return m.group(1)
def _signup(client, name, email, password="parola_test_001"):
tok = _csrf(client, "/signup")
resp = client.post("/signup", data={"name": name, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
assert resp.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
(email,)).fetchone()
return int(row["account_id"])
finally:
conn.close()
def _make_admin(account_id):
from app.db import get_connection
from app.users import set_admin
conn = get_connection()
try:
set_admin(conn, account_id, is_admin=True)
conn.commit()
finally:
conn.close()
def _login(client, email, password="parola_test_001"):
tok = _csrf(client, "/login")
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": tok})
assert resp.status_code == 303
def _status(account_id):
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT status FROM accounts WHERE id=?", (account_id,)).fetchone()
return row["status"] if row else None
finally:
conn.close()
def _admin_login(client):
admin_id = _signup(client, "Admin SA", "admin@test.ro")
_make_admin(admin_id)
_login(client, "admin@test.ro")
return admin_id
@pytest.mark.parametrize("verb,expected", [
("block", "blocked"),
("archive", "archived"),
("delete", "deleted"),
])
def test_lifecycle_single(client, verb, expected):
target = _signup(client, "Tinta SRL", "tinta@test.ro")
_admin_login(client)
tok = _csrf(client)
resp = client.post(f"/admin/{verb}", data={"account_id": target, "csrf_token": tok})
assert resp.status_code == 303
assert _status(target) == expected
def test_bulk_pe_lista_account_id(client):
a = _signup(client, "A SRL", "a@test.ro")
b = _signup(client, "B SRL", "b@test.ro")
_admin_login(client)
tok = _csrf(client)
resp = client.post("/admin/block", data={"account_id": [a, b], "csrf_token": tok})
assert resp.status_code == 303
assert _status(a) == "blocked" and _status(b) == "blocked"
def test_bulk_sare_contul_dev(client):
target = _signup(client, "Tinta SRL", "t2@test.ro")
_admin_login(client)
tok = _csrf(client)
# include id=1 (cont de sistem) in selectie -> sarit, fara eroare; tinta procesata
resp = client.post("/admin/archive", data={"account_id": [1, target], "csrf_token": tok})
assert resp.status_code == 303
assert _status(1) == "active", "contul dev id=1 trebuie sa ramana neatins"
assert _status(target) == "archived"
def test_non_admin_403(client):
target = _signup(client, "Tinta SRL", "t3@test.ro")
_signup(client, "Neadmin SRL", "plain@test.ro")
_login(client, "plain@test.ro")
# csrf de pe o pagina accesibila non-admin
tok = _csrf(client, "/")
resp = client.post("/admin/block", data={"account_id": target, "csrf_token": tok})
assert resp.status_code == 403
def test_csrf_obligatoriu(client):
target = _signup(client, "Tinta SRL", "t4@test.ro")
_admin_login(client)
resp = client.post("/admin/delete", data={"account_id": target}) # fara csrf_token
assert resp.status_code != 303
assert _status(target) != "deleted"

View File

@@ -103,13 +103,13 @@ def _macro_html(checked: bool = True, form_id: str = "") -> str:
# --- markup / copy ---
def test_comutator_coada_prezent():
"""Textul tinteste COADA ("in coada"/"verificare"), NU "trimite"/"Manual" gol."""
"""5.5 (supersede framing 3.6): comutator etichetat Auto/Manual, compact.
Send-safety pastrata prin tooltip/Ajutor (Manual = tine pentru verificare; nimic nu
pleaca la RAR pana confirmi). Semantica de prezenta name=auto_send nealterata."""
html = _macro_html()
assert "in coada" in html, "comutatorul trebuie sa vorbeasca despre coada"
assert "verificare" in html, "optiunea de verificare manuala trebuie prezenta"
assert "name=\"auto_send\"" in html and 'value="true"' in html
# framing periculos interzis (citit global = send-safety):
assert "Manual" not in html, "fara 'Manual' gol (sugereaza bypass al confirmarii RAR)"
assert "Auto" in html and "Manual" in html, "ambele stari etichetate"
assert "verificare" in html, "sensul de verificare manuala trebuie pastrat (tooltip/ajutor)"
assert "trimite" not in html.lower(), "fara cuvantul 'trimite' izolat in eticheta"
assert "auto-send" not in html, "jargonul 'auto-send' trebuie inlocuit"
@@ -179,8 +179,9 @@ def test_comutator_in_tab_mapari(client):
_login(client, "tm@test.com")
resp = client.get("/?tab=mapari")
assert resp.status_code == 200
assert "Pune automat in coada" in resp.text
assert "aceasta operatie" in resp.text
# 5.5: comutatorul compact Auto/Manual e prezent in tabul Mapari
assert 'name="auto_send"' in resp.text
assert "Manual" in resp.text and "Auto" in resp.text
def test_comutator_in_panou_preview(client):
@@ -210,5 +211,6 @@ def test_comutator_in_panou_preview(client):
})
assert r.status_code == 200
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
assert "Pune automat in coada" in r.text
assert "aceasta operatie" in r.text
# 5.5: comutatorul compact Auto/Manual e prezent si in panoul de mapare din preview
assert 'name="auto_send"' in r.text
assert "Manual" in r.text and "Auto" in r.text

128
tests/test_web_admin.py Normal file
View File

@@ -0,0 +1,128 @@
"""Teste US-009 (PRD 5.5): panou admin UI — selectie cu bife + master, bara de actiuni bulk
(Activeaza/Blocheaza/Arhiveaza/Sterge), actiuni per-rand, fara nota 'cont dev implicit',
grila standard.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_web_admin.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
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
get_settings.cache_clear()
def _csrf(client, url):
resp = client.get(url, follow_redirects=True)
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
return m.group(1)
def _signup(client, name, email, password="parola_test_001"):
tok = _csrf(client, "/signup")
client.post("/signup", data={"name": name, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
(email,)).fetchone()
return int(row["account_id"])
finally:
conn.close()
def _admin_login(client):
target = _signup(client, "Pending SRL", "pending@test.ro") # cont in asteptare
admin_id = _signup(client, "Admin SA", "admin@test.ro")
from app.db import get_connection
from app.users import set_admin
conn = get_connection()
try:
set_admin(conn, admin_id, is_admin=True)
conn.commit()
finally:
conn.close()
tok = _csrf(client, "/login")
resp = client.post("/login", data={"email": "admin@test.ro", "parola": "parola_test_001",
"csrf_token": tok})
assert resp.status_code == 303
return target
def test_admin_coloana_selectie_si_master(client):
_admin_login(client)
html = client.get("/admin").text
# checkbox de selectie pe rand + master
assert 'name="account_id"' in html
assert 'type="checkbox"' in html
assert "Selecteaza tot" in html or 'data-master' in html
def test_bara_bulk_cu_cele_4_verbe(client):
_admin_login(client)
html = client.get("/admin").text
assert 'formaction="/admin/activate"' in html
assert 'formaction="/admin/block"' in html
assert 'formaction="/admin/archive"' in html
assert 'formaction="/admin/delete"' in html
# bara e ascunsa initial (hidden), fara display inline care ar invinge [hidden]
assert re.search(r'class="bulk-bar"\s+hidden', html) or re.search(r'hidden[^>]*class="bulk-bar"', html)
assert "bulk-bar" in html and ".bulk-bar[hidden]" in html # CSS care face hidden eficient
def test_actiuni_per_rand(client):
_admin_login(client)
html = client.get("/admin").text
# forme per-rand catre rutele de lifecycle (kebab)
assert 'action="/admin/block"' in html
assert 'action="/admin/archive"' in html
assert 'action="/admin/delete"' in html
def test_fara_nota_cont_dev(client):
_admin_login(client)
html = client.get("/admin").text
assert "cont dev implicit" not in html.lower()
assert "Cont dev implicit" not in html
def test_grila_standard(client):
_admin_login(client)
html = client.get("/admin").text
assert "tablewrap" in html
def test_cont_arhivat_in_blocul_suspendate(client):
"""Gruparea pe STARE: un cont arhivat apare in blocul blocate/arhivate, nu in 'in asteptare'."""
target = _admin_login(client) # cont pending seedat
from app.db import get_connection
from app.accounts import set_status
conn = get_connection()
try:
set_status(conn, target, "archived")
conn.commit()
finally:
conn.close()
html = client.get("/admin").text
# contul arhivat ajunge in blocul suspendate (1 cont), nu in "in asteptare"
assert re.search(r"Conturi blocate / arhivate \(1\)", html)
assert ">archived<" in html

View File

@@ -0,0 +1,86 @@
"""Teste US-006 (PRD 5.5): meniu hamburger in header (Cont/Integrare/Nomenclator/Admin/logout)
+ context de autentificare. base.html partajat: pe login/signup meniul nu expune cont/logout.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "menu_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 _make_user(email="u@test.com", password="parolasecreta10", is_admin=False):
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", active=True)
create_user(conn, acct_id, email, password, is_admin=is_admin)
return acct_id
finally:
conn.close()
def _login(client, email="u@test.com", password="parolasecreta10"):
resp = client.get("/login")
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
assert resp.status_code == 303
def test_meniu_autentificat_are_linkuri_cont(client):
_make_user()
_login(client)
html = client.get("/").text
# butonul de meniu (hamburger) prezent
assert 'id="cont-menu-toggle"' in html
assert 'aria-controls="cont-menu"' in html
# linkurile mutate in meniu
assert 'href="/?tab=cont"' in html
assert 'href="/?tab=integrare"' in html
assert 'href="/?tab=nomenclator"' in html
# logout in meniu
assert 'action="/logout"' in html
assert "Iesi din cont" in html
def test_meniu_admin_link_doar_pentru_admin(client):
_make_user(email="admin@test.com", is_admin=True)
_login(client, email="admin@test.com")
html = client.get("/").text
assert 'href="/admin"' in html
def test_meniu_fara_admin_pentru_neadmin(client):
_make_user(email="plain@test.com", is_admin=False)
_login(client, email="plain@test.com")
html = client.get("/").text
assert 'href="/admin"' not in html
def test_meniu_neautentificat_fara_logout(client):
"""Pe /login (neautentificat) meniul nu expune cont/logout."""
html = client.get("/login").text
assert "Iesi din cont" not in html
assert 'action="/logout"' not in html
assert 'id="cont-menu-toggle"' not in html

View File

@@ -28,12 +28,17 @@ def _starile_din_schema() -> list[str]:
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.
# Nota (5.5): de cand exista si `accounts.status` cu propriul CHECK, ancoram pe blocul
# tabelei submissions (`CREATE TABLE ... submissions`) ca sa nu prindem starile de cont.
tbl = re.search(
r"CREATE TABLE[^;]*?submissions\b(.*?);", sql, re.IGNORECASE | re.DOTALL
)
assert tbl, "Nu am gasit CREATE TABLE submissions in schema.sql — schema s-a schimbat?"
match = re.search(
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
sql,
tbl.group(1),
)
assert match, "Nu am gasit CHECK (status IN (...)) in schema.sql — schema s-a schimbat?"
assert match, "Nu am gasit CHECK (status IN (...)) in submissions — schema s-a schimbat?"
raw = match.group(1)
# Extrage valorile dintre ghilimele simple

View File

@@ -73,7 +73,7 @@ def client(monkeypatch):
# ============================================================
def test_dashboard_are_tabbar(client):
"""Dashboard-ul contine un tab-bar cu cele 6 tab-uri."""
"""US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
_create_account_user("tabbar@test.com", "parolasecreta10")
_login(client, "tabbar@test.com", "parolasecreta10")
@@ -83,12 +83,15 @@ def test_dashboard_are_tabbar(client):
html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist"
# 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)"
# Doar Acasa + Mapari sunt tab-uri (role="tab")
assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
# Cont/Integrare/Nomenclator NU mai sunt tab-uri
for label in ("Cont", "Integrare", "Nomenclator", "Import"):
assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \
f"'{label}' nu ar mai trebui sa fie un tab separat (mutat in meniu)"
# ...dar traiesc in meniul de cont
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
# ============================================================
@@ -215,3 +218,20 @@ def test_tabbar_aria(client):
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"
# ============================================================
# test_fragmentele_mutate_raman_accesibile (US-007)
# ============================================================
def test_fragmentele_mutate_raman_accesibile(client):
"""US-007 (5.5): Cont/Integrare/Nomenclator s-au mutat in meniu, dar rutele de fragment
si deep-link-ul ?tab= raman valide (zero rute moarte / 404)."""
_create_account_user("frag@test.com", "parolasecreta10")
_login(client, "frag@test.com", "parolasecreta10")
for tab in ("cont", "integrare", "nomenclator"):
r_frag = client.get(f"/_fragments/{tab}")
assert r_frag.status_code == 200, f"/_fragments/{tab} a dat {r_frag.status_code}"
r_deep = client.get(f"/?tab={tab}")
assert r_deep.status_code == 200, f"/?tab={tab} a dat {r_deep.status_code}"

View File

@@ -0,0 +1,171 @@
"""Teste PRD 5.5 — uniformizare UI: US-001 (Acasa fara Ajutor), US-002 (Nomenclator grila
standard), US-003 (macro autosend compact). Stories de template/macro -> render direct Jinja
pentru US-002/003; US-001 prin TestClient pe fragmentul Acasa.
"""
from __future__ import annotations
import os
import re
import tempfile
from pathlib import Path
import pytest
from jinja2 import Environment, FileSystemLoader
from starlette.testclient import TestClient
_TEMPLATES = Path(__file__).resolve().parents[1] / "app" / "web" / "templates"
def _env():
return Environment(loader=FileSystemLoader(str(_TEMPLATES)), autoescape=True)
# ============================================================
# US-002: Nomenclator ca tabel standard (grila Trimiteri)
# ============================================================
def test_nomenclator_grila_standard_cu_randuri():
tmpl = _env().get_template("_nomenclator.html")
html = tmpl.render(rows=[
{"cod_prestatie": "A012", "nume_prestatie": "Revizie tehnica", "updated_at": "2026-06-20"},
])
assert "tablewrap" in html
assert "<table" in html
assert 'class="pill"' in html # codul in pill, ca la Trimiteri
assert "A012" in html and "Revizie tehnica" in html
def test_nomenclator_empty_state():
tmpl = _env().get_template("_nomenclator.html")
html = tmpl.render(rows=[])
assert 'class="empty"' in html
assert "Nomenclator gol" in html
# ============================================================
# US-003: macro autosend_toggle compact (Auto/Manual)
# ============================================================
def _render_macro(form_id="map-1", checked=True):
mod = _env().get_template("_macros.html").module
return str(mod.autosend_toggle(form_id=form_id, checked=checked))
def test_autosend_pastreaza_name_si_prezenta():
"""Invariant backend: checkbox name=auto_send value=true (semantica de prezenta)."""
html = _render_macro(checked=True)
assert 'type="checkbox"' in html
assert 'name="auto_send"' in html
assert 'value="true"' in html
assert 'form="map-1"' in html
assert "checked" in html
def test_autosend_nebifat_fara_checked():
html = _render_macro(checked=False)
assert 'name="auto_send"' in html
assert "checked" not in html
def test_autosend_compact_fara_proza_inline():
"""Proza explicativa de pe randuri (3.6) eliminata din CONTINUTUL vizibil — traieste in
panoul Ajutor (US-005). Tooltip-ul scurt (atribut title=) e acceptat, deci il scoatem
inainte de verificare."""
html = _render_macro()
vizibil = re.sub(r'title="[^"]*"', "", html) # scoate atributul title (tooltip)
assert "La fisierele viitoare" not in vizibil
assert "Tine pentru verificare" not in vizibil
assert "nimic nu pleaca la RAR" not in vizibil
# ambele etichete de stare vizibile, compact
assert "Auto" in html and "Manual" in html
# ============================================================
# US-001: Acasa fara sectiunea Ajutor
# ============================================================
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "uniform_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 _login(client, email="u@test.com", password="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", active=True)
create_user(conn, acct_id, email, password)
finally:
conn.close()
resp = client.get("/login")
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
assert resp.status_code == 303
return acct_id
def test_acasa_fara_sectiune_ajutor(client):
_login(client)
resp = client.get("/_fragments/acasa")
assert resp.status_code == 200
# randul "Ajutor:" cu wayfinding Mapari/Coduri RAR eliminat din Acasa
assert "Ajutor:" not in resp.text
assert "Coduri RAR" not in resp.text
# ============================================================
# US-005: Tabel Mapari standardizat + panou Ajutor
# ============================================================
def _seed_needs_mapping(acct_id, cod_op="OP-NM", denumire="Operatie test"):
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 (?, ?, 'needs_mapping', ?)",
(f"k-{cod_op}", acct_id,
json.dumps({"prestatii": [{"cod_op_service": cod_op, "denumire": denumire}]})),
)
conn.commit()
finally:
conn.close()
def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
acct = _login(client)
_seed_needs_mapping(acct)
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# panou Ajutor (<details>) prezent
assert "ajutor-mapari" in html
assert "<details" in html and ">Ajutor<" in html
# antet de coloana compact
assert ">In coada<" in html
# proza inline veche eliminata de pe sectiuni
assert "sugestia fuzzy e preselectata) si salveaza" not in html
assert "Maparile operatie -> cod RAR retinute pentru contul tau" not in html
def test_mapari_comutator_compact_in_tabel(client):
acct = _login(client)
_seed_needs_mapping(acct)
html = client.get("/_fragments/mapari").text
assert 'name="auto_send"' in html
assert "Manual" in html and "Auto" in html

View File

@@ -125,6 +125,51 @@ def test_claim_account_null_tratat_activ(env):
assert _row_status(conn, sid) == "sending"
def test_claim_sare_cont_blocat(env):
"""5.5: cont blocked -> claim_one nu ridica submission-ul."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Blocat", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "blocked")
assert claim_one(conn) is None
assert _row_status(conn, sid) == "queued"
def test_claim_sare_cont_arhivat(env):
"""5.5: cont archived -> claim_one nu ridica submission-ul."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Arhivat", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "archived")
assert claim_one(conn) is None
assert _row_status(conn, sid) == "queued"
def test_deblocare_reia_trimiterea(env):
"""5.5: blocked -> set_status('active') -> claim_one ridica din nou."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Revenit", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "blocked")
assert claim_one(conn) is None
set_status(conn, acct_id, "active")
result = claim_one(conn)
assert result is not None and result["id"] == sid
assert _row_status(conn, sid) == "sending"
def test_claim_cont_legacy_fara_active(env):
"""Simuleaza cont legacy: LEFT JOIN nu gaseste randul in accounts (account_id nul dupa stergere cont)
-> a.active=NULL dupa JOIN -> COALESCE(NULL,1)=1 -> tratat ca activ.