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:
@@ -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):
|
||||
|
||||
160
tests/test_account_status.py
Normal file
160
tests/test_account_status.py
Normal 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"
|
||||
@@ -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"}
|
||||
|
||||
137
tests/test_admin_lifecycle.py
Normal file
137
tests/test_admin_lifecycle.py
Normal 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"
|
||||
@@ -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
128
tests/test_web_admin.py
Normal 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
|
||||
86
tests/test_web_header_menu.py
Normal file
86
tests/test_web_header_menu.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
171
tests/test_web_uniformizare.py
Normal file
171
tests/test_web_uniformizare.py
Normal 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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user