feat(5.16+5.17): tipografie/antet branded + tipuri cont, planuri si enforcement

PRD 5.16 — propagare design finalizata (system font stack, fara IBM Plex self-hostat):
- US-001/002/008: tokeni --font-ui/--font-mono (system stack) + scala --fs-*; zero
  @font-face si zero /static/fonts/; landing aliniat la acelasi stack
- US-003: RAR online = dot compact in antet + meniu burger; banda rosie DOAR pe blocat
  (invariant zero-silent-failures pastrat)
- US-010: antet "ROMFAST AUTOPASS" + nume service + /login brandeit 2 coloane + badge plan;
  meniu burger cu separatoare; gate strict pe is_authenticated
- US-011: selector tema pill icon+eticheta (reuse THEMES)
- US-004/005/006/007: bug-fix editor prestatii (picker cod+denumire, add_extra in mod
  operatii, cod ales se salveaza fara "+", Renunta inchide via closest)
- US-012/013: landing Autentificare->/login; wizard import colapsat + 4 pasi pe tokeni
- fix VERIFY E2E: contoare duplicate pe 390px (inline display:flex batea @media) -> CSS + test-lock

PRD 5.17 — tipuri de cont + trial Pro 30z + enforcement DUR:
- US-001/002/008: accounts.tier + trial_until (migrare aditiva defensiva); app/plans.py
  sursa unica (PLANS, FREE_MONTHLY_LIMIT=60, effective_tier(now injectabil), monthly_usage,
  CONSUMED_STATUSES); create_account trial Pro 30z; CLI set-tier (protejat id=1, audit)
- US-003/004/005: enforce volum 60/luna INAINTE de build_key pe ambele canale
  (PLAN_LIMITA_LUNARA, 3 niveluri + log_event); gate API Pro+ (PLAN_FARA_API 403 actionabil);
  valideaza/nomenclator raman permise; downgrade lazy; flag AUTOPASS_ENFORCE_PLANS (kill-switch)
- US-006: badge plan antet + linie burger + consum N/60 + warn>=80% + 6 stari + copy RO
  pluralizat + banner one-time trial->Gratuit + pagina Cont

Regresie: 1380 passed, 0 failed, 1 deselected (live). E2E browser pe 390/1280 confirmat.
Backend trimitere (worker/masina stari/idempotenta/contract RAR) NEATINS. Lucrul 5.18
(corpus kNN) ramane separat, necomis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-29 06:01:05 +00:00
parent 9eccb9f6fa
commit c9f9a1ca0e
37 changed files with 3433 additions and 449 deletions

View File

@@ -112,7 +112,7 @@ 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", "email", "active", "status", "created_at"}
assert set(r.keys()) == {"id", "name", "cui", "email", "active", "status", "created_at", "tier", "trial_until"}
# ---------------------------------------------------------------------------
@@ -170,3 +170,221 @@ def test_account_is_complete_false_pe_legacy_incomplet(conn):
# contul sistem id=1 e EXCEPTAT (returneaza True indiferent)
row_sys = conn.execute("SELECT * FROM accounts WHERE id=1").fetchone()
assert account_is_complete(row_sys) is True
# ---------------------------------------------------------------------------
# 5.17 US-001/US-008: schema tier + trial_until + set_tier + CLI set-tier
# ---------------------------------------------------------------------------
def test_migrare_tier_trial_defensiva(conn):
"""_migrate adauga tier + trial_until pe conturi existente, e idempotenta."""
from app.db import _migrate
cols_before = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "tier" in cols_before
assert "trial_until" in cols_before
# a doua rulare: idempotenta (nu arunca)
_migrate(conn)
cols_after = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "tier" in cols_after
assert "trial_until" in cols_after
def test_cont_nou_tier_free_si_trial_30z(conn):
"""create_account seteaza tier='free' si trial_until = acum + ~30 zile."""
from datetime import datetime, timezone, timedelta
from app.accounts import create_account
before = datetime.now(timezone.utc)
acct_id = create_account(conn, "Service Trial", cui="RO_T1")
after = datetime.now(timezone.utc)
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["tier"] == "free"
assert row["trial_until"] is not None
tu = datetime.fromisoformat(row["trial_until"].replace(" ", "T"))
if tu.tzinfo is None:
tu = tu.replace(tzinfo=timezone.utc)
# trial_until trebuie sa fie intre now+29z si now+31z
assert tu >= before + timedelta(days=29)
assert tu <= after + timedelta(days=31)
def test_cont_nou_effective_tier_pro_in_trial(conn):
"""Cont nou are effective_tier='pro' (trial activ)."""
from datetime import datetime, timezone
from app.accounts import create_account
from app.plans import effective_tier
acct_id = create_account(conn, "Service Pro Trial", cui="RO_T2")
row = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id,)).fetchone()
now = datetime.now(timezone.utc)
assert effective_tier(row, now) == "pro"
def test_set_tier_valid(conn):
"""set_tier seteaza tier-ul corect."""
from app.accounts import create_account, set_tier
acct_id = create_account(conn, "Service Tier", cui="RO_T3")
set_tier(conn, acct_id, "pro")
row = conn.execute("SELECT tier FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["tier"] == "pro"
def test_set_tier_cu_trial(conn):
"""set_tier cu trial_until seteaza ambele campuri."""
from app.accounts import create_account, set_tier
acct_id = create_account(conn, "Service Tier Trial", cui="RO_T4")
set_tier(conn, acct_id, "standard", trial_until="2026-12-31 00:00:00")
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["tier"] == "standard"
assert row["trial_until"] == "2026-12-31 00:00:00"
def test_set_tier_no_trial_sterge_trial_until(conn):
"""set_tier cu trial_until=None sterge trial-ul existent."""
from app.accounts import create_account, set_tier
acct_id = create_account(conn, "Service No Trial", cui="RO_T5")
# mai intai setam un trial
set_tier(conn, acct_id, "pro", trial_until="2026-12-31 00:00:00")
# acum stergem trial-ul
set_tier(conn, acct_id, "pro", trial_until=None)
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["tier"] == "pro"
assert row["trial_until"] is None
def test_set_tier_invalid_respins(conn):
"""set_tier cu tier invalid ridica ValueError cu mesaj clar."""
from app.accounts import create_account, set_tier
acct_id = create_account(conn, "Service Tier Invalid", cui="RO_T6")
with pytest.raises(ValueError, match="tier invalid"):
set_tier(conn, acct_id, "gold")
def test_set_tier_protejeaza_id1(conn):
"""set_tier pe contul de sistem id=1 ridica ValueError (protejat)."""
from app.accounts import set_tier
with pytest.raises(ValueError):
set_tier(conn, 1, "pro")
def test_set_tier_cont_inexistent_ridica(conn):
"""set_tier pe cont inexistent ridica ValueError."""
from app.accounts import set_tier
with pytest.raises(ValueError, match="inexistent"):
set_tier(conn, 9999, "pro")
def test_list_accounts_include_tier_trial(conn):
"""list_accounts include coloanele tier si trial_until."""
from app.accounts import create_account, list_accounts
create_account(conn, "Service List", cui="RO_L1")
rows = list_accounts(conn)
for r in rows:
assert "tier" in r
assert "trial_until" in r
def test_default_account_tier_free_fara_trial(conn):
"""Contul implicit id=1 (creat de schema) are tier='free' si trial_until=NULL."""
row = conn.execute("SELECT tier, trial_until FROM accounts WHERE id=1").fetchone()
assert row["tier"] == "free"
assert row["trial_until"] is None
def test_cli_set_tier(monkeypatch):
"""CLI set-tier seteaza tier-ul unui cont (--no-trial)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier.db"))
from app.config import get_settings
get_settings.cache_clear()
from tools.account import main
from app.db import init_db, get_connection
from app.accounts import create_account
init_db()
conn_tmp = get_connection()
acct_id = create_account(conn_tmp, "CLI Test", cui="RO_CLI1")
conn_tmp.close()
rc = main(["set-tier", "--account", str(acct_id), "--tier", "pro", "--no-trial"])
assert rc == 0
conn_tmp2 = get_connection()
row = conn_tmp2.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
conn_tmp2.close()
assert row["tier"] == "pro"
assert row["trial_until"] is None
get_settings.cache_clear()
def test_cli_set_tier_cu_trial_days(monkeypatch):
"""CLI set-tier cu --trial-days 14 seteaza trial_until = acum + 14z."""
from datetime import datetime, timezone, timedelta
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier2.db"))
from app.config import get_settings
get_settings.cache_clear()
from tools.account import main
from app.db import init_db, get_connection
from app.accounts import create_account
init_db()
conn_tmp = get_connection()
acct_id = create_account(conn_tmp, "CLI Trial", cui="RO_CLI2")
conn_tmp.close()
before = datetime.now(timezone.utc)
rc = main(["set-tier", "--account", str(acct_id), "--tier", "pro", "--trial-days", "14"])
assert rc == 0
after = datetime.now(timezone.utc)
conn_tmp2 = get_connection()
row = conn_tmp2.execute("SELECT tier, trial_until FROM accounts WHERE id=?", (acct_id,)).fetchone()
conn_tmp2.close()
assert row["tier"] == "pro"
assert row["trial_until"] is not None
tu = datetime.fromisoformat(row["trial_until"].replace(" ", "T")).replace(tzinfo=timezone.utc)
assert tu >= before + timedelta(days=13)
assert tu <= after + timedelta(days=15)
get_settings.cache_clear()
def test_cli_set_tier_invalid(monkeypatch):
"""CLI set-tier cu tier invalid: exit code != 0."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_cli_tier3.db"))
from app.config import get_settings
get_settings.cache_clear()
from tools.account import main
from app.db import init_db, get_connection
from app.accounts import create_account
init_db()
conn_tmp = get_connection()
acct_id = create_account(conn_tmp, "CLI Invalid", cui="RO_CLI3")
conn_tmp.close()
rc = main(["set-tier", "--account", str(acct_id), "--tier", "diamond"])
assert rc != 0
get_settings.cache_clear()

View File

@@ -224,3 +224,309 @@ def test_get_listare_filtru_status_nu_sparge_scope(client):
assert VIN_A not in vins, (
"Filtrul status a scos date din alt cont (scurgere cross-account prin filtru)."
)
# ============================================================================
# PRD 5.17 — enforcement planuri: gate API (T4) + volum (T3) + kill-switch (T5)
# ============================================================================
_PREZ_PLAN = {
"vin": "WVWZZZ1KZAW900001",
"nr_inmatriculare": "B900TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_op_service": "OP-PLAN-T4", "denumire": "Test plan gate"}],
}
def _set_tier_acct(account_id: int, tier: str, trial_until=None) -> None:
"""Seteaza tier si trial_until pe un cont existent."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET tier=?, trial_until=? WHERE id=?",
(tier, trial_until, account_id),
)
conn.commit()
finally:
conn.close()
def _insert_n_submissions(account_id: int, n: int) -> None:
"""Insereaza N submissions queued in luna curenta pentru cont."""
from datetime import datetime, timezone
from app.db import get_connection
conn = get_connection()
try:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
for i in range(n):
conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, created_at) "
"VALUES (?, ?, 'queued', '{}', ?)",
(f"plan-seed-{account_id}-{i}-{os.urandom(4).hex()}", account_id, now_str),
)
conn.commit()
finally:
conn.close()
# ---------------------------------------------------------------------------
# T4: Gate API — free/standard NU pot accesa rutele de ingestie API
# ---------------------------------------------------------------------------
def test_free_fara_api_403(client):
"""Cont free (non-default, cu cheie API) → 403 PLAN_FARA_API pe POST /v1/prezentari.
T4 PRD 5.17: gate API refuza conturi cu api_access=False in PLANS.
"""
acct_id, key = _create_account_with_key("FreePlanGate")
_set_tier_acct(acct_id, "free", trial_until=None)
resp = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 403, f"Asteptat 403, primit {resp.status_code}: {resp.text}"
detail = resp.json().get("detail", {})
assert detail.get("cod") == "PLAN_FARA_API", f"Cod eroare gresit: {detail}"
def test_standard_fara_api_403(client):
"""Cont standard (non-default) → 403 PLAN_FARA_API pe POST /v1/prezentari.
Standard are api_access=False, deci gate-ul API respinge la fel ca free.
"""
acct_id, key = _create_account_with_key("StdPlanGate")
_set_tier_acct(acct_id, "standard", trial_until=None)
resp = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 403, f"Asteptat 403, primit {resp.status_code}"
assert resp.json().get("detail", {}).get("cod") == "PLAN_FARA_API"
def test_pro_api_ok(client):
"""Cont pro (non-default) → 200 pe POST /v1/prezentari (trece gate-ul API)."""
acct_id, key = _create_account_with_key("ProPlanGate")
_set_tier_acct(acct_id, "pro", trial_until=None)
resp = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, (
f"Pro trebuie sa aiba acces API, primit: {resp.status_code}: {resp.text}"
)
def test_trial_pro_api_ok(client):
"""Cont free cu trial Pro activ → 200 pe POST /v1/prezentari.
effective_tier() intoarce 'pro' cand trial_until > now, deci gate-ul permite.
"""
from datetime import datetime, timedelta, timezone
acct_id, key = _create_account_with_key("TrialPlanGate")
trial_until = (datetime.now(timezone.utc) + timedelta(days=15)).strftime("%Y-%m-%d %H:%M:%S")
_set_tier_acct(acct_id, "free", trial_until=trial_until)
resp = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, (
f"Trial Pro trebuie sa treaca gate-ul API, primit: {resp.status_code}"
)
def test_dry_run_valideaza_ramane_permis(client):
"""POST /v1/prezentari/valideaza (dry-run) e permis pe orice plan, inclusiv free.
Decizie design (PRD 5.17): valideaza nu face enqueue si nu consuma cota,
deci NU e protejat de gate-ul API — integratorii pot testa fara upgrade.
"""
acct_id, key = _create_account_with_key("FreeValideazaTest")
_set_tier_acct(acct_id, "free", trial_until=None)
resp = client.post(
"/v1/prezentari/valideaza",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": key},
)
assert resp.status_code == 200, (
f"valideaza trebuie permis pe plan free (fara gate API), "
f"primit {resp.status_code}: {resp.text}"
)
# ---------------------------------------------------------------------------
# T3: Enforce volum — limita 60/luna pe planul Gratuit
# Dev account (id=1) are bypass la gate-ul API, dar NU la verificarea de volum
# ---------------------------------------------------------------------------
def test_free_peste_60_respins_api(client):
"""Dev account (id=1) la 60/60 pe free → a 61-a prezentare respinsa 422 PLAN_LIMITA_LUNARA.
Dev account (id=1) in dev mode (require_api_key=False) are bypass la gate-ul API (T4),
dar NU la verificarea de volum (T3). La 60/60, urmatoarea cerere trebuie respinsa 422.
"""
_set_tier_acct(1, "free", trial_until=None)
_insert_n_submissions(1, 60)
# A 61-a cerere (fara cheie = cont 1 in dev mode, bypass gate API, dar volumul e plin)
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
assert resp.status_code == 422, (
f"Asteptat 422 la depasire volum, primit {resp.status_code}: {resp.text}"
)
detail = resp.json().get("detail", {})
assert detail.get("cod") == "PLAN_LIMITA_LUNARA", f"Cod gresit: {detail}"
def test_eroare_3_niveluri_plan_limita(client):
"""Eroarea PLAN_LIMITA_LUNARA contine toate 3 nivelurile: cod, problema, cauza, fix.
Pattern standard (PRD 5.17): eroare structurata pe 3 niveluri — nu 500, nu catch-all.
"""
_set_tier_acct(1, "free", trial_until=None)
_insert_n_submissions(1, 60)
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
assert resp.status_code == 422
detail = resp.json().get("detail", {})
assert detail.get("cod") == "PLAN_LIMITA_LUNARA", f"Camp 'cod' gresit: {detail}"
assert detail.get("problema"), f"Camp 'problema' lipsa: {detail}"
assert detail.get("cauza"), f"Camp 'cauza' lipsa: {detail}"
assert detail.get("fix"), f"Camp 'fix' lipsa: {detail}"
def test_pro_si_trial_nelimitat(client):
"""Pro si trial Pro nu sunt blocati de volum indiferent de numarul de submissions.
PLANS['pro']['monthly_limit'] is None -> nelimitat; la fel trial Pro activ.
"""
from datetime import datetime, timedelta, timezone
# Cont pro cu 70 submissions (peste limita free de 60)
pro_id, pro_key = _create_account_with_key("ProVolumTest")
_set_tier_acct(pro_id, "pro", trial_until=None)
_insert_n_submissions(pro_id, 70)
# Cont trial Pro cu 70 submissions
trial_id, trial_key = _create_account_with_key("TrialVolumTest")
trial_until = (datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
_set_tier_acct(trial_id, "free", trial_until=trial_until)
_insert_n_submissions(trial_id, 70)
# Pro: trebuie 200 (nu 422 de volum)
r_pro = client.post(
"/v1/prezentari",
json={"prezentari": [_PREZ_PLAN]},
headers={"X-API-Key": pro_key},
)
assert r_pro.status_code == 200, f"Pro trebuie sa fie nelimitat, primit: {r_pro.text}"
# Trial Pro: trebuie 200 (nu 422 de volum)
prez2 = dict(_PREZ_PLAN, vin="WVWZZZ1KZAW900002", nr_inmatriculare="B900TS2")
r_trial = client.post(
"/v1/prezentari",
json={"prezentari": [prez2]},
headers={"X-API-Key": trial_key},
)
assert r_trial.status_code == 200, f"Trial Pro trebuie sa fie nelimitat, primit: {r_trial.text}"
def test_retry_idempotent_nu_consuma_cota(client):
"""Un retry idempotent al aceleiasi prestatii nu consuma cota de doua ori.
Invariant idempotenta (arhitectura): monthly_usage creste O SINGURA DATA
per submission unic. Retryurile (acelasi idempotency_key) sunt dedup-ate,
deci usage ramine la 1 dupa doua trimiteri identice.
Folsim cod_prestatie "OE-1" (in nomenclatorul seed) ca sa obtinem status
'queued' (statusuri "needs_mapping" nu se numara in monthly_usage).
"""
from datetime import datetime, timezone
from app.plans import monthly_usage
from app.db import get_connection
_set_tier_acct(1, "free", trial_until=None)
# cod_prestatie "OE-1" e in nomenclatorul seed -> submission va fi 'queued' (contat in usage)
prez_unic = {
"vin": "WVWZZZ1KZAW901001",
"nr_inmatriculare": "B901TST",
"data_prestatie": "2026-06-15",
"odometru_final": "50000",
"prestatii": [{"cod_prestatie": "OE-1", "denumire": "Revizie"}],
}
# Prima trimitere — submission noua
r1 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
assert r1.status_code == 200, f"Prima trimitere trebuie sa treaca: {r1.text}"
assert not r1.json()["results"][0].get("deduped"), "Prima trimitere nu trebuia sa fie deduped"
conn = get_connection()
try:
usage_1 = monthly_usage(conn, 1, datetime.now(timezone.utc))
finally:
conn.close()
assert usage_1 == 1, f"Dupa prima trimitere: asteptat usage=1, primit {usage_1}"
# Retry (acelasi payload -> acelasi idempotency_key -> dedup, fara INSERT nou)
r2 = client.post("/v1/prezentari", json={"prezentari": [prez_unic]})
assert r2.status_code == 200, f"Retry trebuie sa treaca (dedup): {r2.text}"
assert r2.json()["results"][0].get("deduped") is True, "Retry trebuia marcat deduped"
conn = get_connection()
try:
usage_2 = monthly_usage(conn, 1, datetime.now(timezone.utc))
finally:
conn.close()
assert usage_2 == 1, (
f"Retry nu trebuia sa creasca usage: inainte={usage_1}, dupa retry={usage_2}"
)
def test_dev_id1_neblocat(client):
"""Dev account (id=1) in dev mode (require_api_key=False) nu e blocat de gate-ul API.
Bypass explicit in require_api_access: require_api_key=False + account_id==DEFAULT_ACCOUNT_ID
-> skip gate, indiferent de tier. DB proaspata (0 submissions -> fara blocare volum).
"""
_set_tier_acct(1, "free", trial_until=None)
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
assert resp.status_code == 200, (
f"Dev id=1 nu trebuie blocat de gate-ul API in dev mode, "
f"primit {resp.status_code}: {resp.text}"
)
def test_flag_enforce_plans_false_sare_enforcement(client, monkeypatch):
"""Kill-switch AUTOPASS_ENFORCE_PLANS=false sare toate gate-urile de plan.
T5 PRD 5.17: flag de operare pentru debugging sau rollback rapid fara revert de cod.
Chiar si cu free la 60/60, nu trebuie 422 cand flag-ul e oprit.
"""
from app.config import get_settings
monkeypatch.setenv("AUTOPASS_ENFORCE_PLANS", "false")
get_settings.cache_clear()
# Cont dev (id=1) pe free la 60/60 (normalmente respins)
_set_tier_acct(1, "free", trial_until=None)
_insert_n_submissions(1, 60)
resp = client.post("/v1/prezentari", json={"prezentari": [_PREZ_PLAN]})
assert resp.status_code == 200, (
f"Cu enforce_plans=False, enforcement trebuia sarat. Primit: {resp.status_code}"
)
get_settings.cache_clear() # curata cache-ul dupa test

View File

@@ -51,7 +51,8 @@ def test_export_doar_contul_cheii(env):
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
# tier='pro' ca sa treaca gate-ul API (T4 PRD 5.17); testul masoara scoping, nu planuri.
conn.execute("INSERT INTO accounts (id, name, tier) VALUES (2, 'al-doilea', 'pro')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
finally:

View File

@@ -47,7 +47,9 @@ def test_lista_doar_contul_cheii(env):
from app.db import get_connection
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
# tier='pro' pe ambele conturi — testul verifica scoping GET, nu planuri (T4 PRD 5.17).
conn.execute("UPDATE accounts SET tier='pro' WHERE id=1")
conn.execute("INSERT INTO accounts (id, name, tier) VALUES (2, 'al-doilea', 'pro')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
finally:

359
tests/test_plans.py Normal file
View File

@@ -0,0 +1,359 @@
"""Teste US-001/US-002 (PRD 5.17): app/plans.py — definitia planurilor + helperi tier/consum."""
from __future__ import annotations
import os
import tempfile
from datetime import datetime, timedelta, timezone
import pytest
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_plans.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()
# ---------------------------------------------------------------------------
# PLANS — sursa de adevar
# ---------------------------------------------------------------------------
def test_plan_definitii_free():
from app.plans import PLANS, FREE_MONTHLY_LIMIT
p = PLANS["free"]
assert p["monthly_limit"] == FREE_MONTHLY_LIMIT
assert p["monthly_limit"] == 60
assert p["api_access"] is False
assert p["label"] == "Gratuit"
def test_plan_definitii_standard():
from app.plans import PLANS
p = PLANS["standard"]
assert p["monthly_limit"] is None
assert p["api_access"] is False
assert "label" in p
def test_plan_definitii_pro():
from app.plans import PLANS
p = PLANS["pro"]
assert p["monthly_limit"] is None
assert p["api_access"] is True
assert "label" in p
def test_plan_definitii_premium():
from app.plans import PLANS
p = PLANS["premium"]
assert p["monthly_limit"] is None
assert p["api_access"] is True
assert "label" in p
def test_toate_tierurile_prezente():
from app.plans import PLANS
assert set(PLANS.keys()) == {"free", "standard", "pro", "premium"}
def test_consumed_statuses_exportata():
from app.plans import CONSUMED_STATUSES
assert "queued" in CONSUMED_STATUSES
assert "sending" in CONSUMED_STATUSES
assert "sent" in CONSUMED_STATUSES
# statusuri blocate nu se numara
assert "error" not in CONSUMED_STATUSES
assert "needs_mapping" not in CONSUMED_STATUSES
assert "needs_data" not in CONSUMED_STATUSES
def test_free_monthly_limit_constanta():
"""FREE_MONTHLY_LIMIT e o singura constanta (DRY), referita din PLANS."""
from app.plans import FREE_MONTHLY_LIMIT, PLANS
assert isinstance(FREE_MONTHLY_LIMIT, int)
assert FREE_MONTHLY_LIMIT == 60
# PLANS["free"]["monthly_limit"] refera aceeasi valoare (nu hardcodat separat)
assert PLANS["free"]["monthly_limit"] == FREE_MONTHLY_LIMIT
# ---------------------------------------------------------------------------
# effective_tier
# ---------------------------------------------------------------------------
def _now_utc():
return datetime.now(timezone.utc)
def test_effective_tier_trial_activ_returneaza_pro():
from app.plans import effective_tier
now = _now_utc()
trial_until = (now + timedelta(days=15)).strftime("%Y-%m-%d %H:%M:%S")
account = {"tier": "free", "trial_until": trial_until}
assert effective_tier(account, now) == "pro"
def test_effective_tier_trial_expirat_returneaza_tier_baza():
from app.plans import effective_tier
now = _now_utc()
trial_until = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
account = {"tier": "free", "trial_until": trial_until}
assert effective_tier(account, now) == "free"
def test_effective_tier_fara_trial_returneaza_tier():
from app.plans import effective_tier
now = _now_utc()
account = {"tier": "standard", "trial_until": None}
assert effective_tier(account, now) == "standard"
def test_effective_tier_plan_platit_nu_downgradat_de_trial_expirat():
"""Un cont pro setat de admin NU e downgradat de expirarea trial-ului."""
from app.plans import effective_tier
now = _now_utc()
# tier=pro, trial_until in trecut: downgrade nu se produce (pro > free)
trial_until = (now - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
account = {"tier": "pro", "trial_until": trial_until}
# tier de baza e pro, deci effective = pro (nu se coboara la free)
assert effective_tier(account, now) == "pro"
def test_effective_tier_trial_malformat_fallback_defensiv():
from app.plans import effective_tier
now = _now_utc()
account = {"tier": "free", "trial_until": "nu-e-o-data-valida"}
# malformat -> fallback la tier de baza, fara exceptie
assert effective_tier(account, now) == "free"
def test_effective_tier_trial_null_fallback():
from app.plans import effective_tier
now = _now_utc()
account = {"tier": "free", "trial_until": None}
assert effective_tier(account, now) == "free"
def test_effective_tier_injectat_determinist():
"""now injectabil: putem simula orice moment — teste deterministe fara datetime.now()."""
from app.plans import effective_tier
# trial_until fix
trial_until = "2026-07-10 12:00:00"
account = {"tier": "free", "trial_until": trial_until}
# inainte de expirare
now_before = datetime(2026, 7, 5, 12, 0, 0, tzinfo=timezone.utc)
assert effective_tier(account, now_before) == "pro"
# dupa expirare
now_after = datetime(2026, 7, 15, 12, 0, 0, tzinfo=timezone.utc)
assert effective_tier(account, now_after) == "free"
def test_effective_tier_premium_cu_trial_pro():
"""premium are api_access=True oricum; trial_until viitor nu strica."""
from app.plans import effective_tier
now = _now_utc()
trial_until = (now + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
account = {"tier": "premium", "trial_until": trial_until}
# trial activ -> 'pro', dar premium e oricum superior (nu ne intereseaza downgrade)
# functia intoarce 'pro' cand trial e activ; consumatorul vede pro (care are api_access)
assert effective_tier(account, now) == "pro"
# ---------------------------------------------------------------------------
# monthly_usage
# ---------------------------------------------------------------------------
def _uid():
"""Cheie idempotenta unica per apel (pentru INSERT in teste)."""
import binascii
return binascii.hexlify(os.urandom(8)).decode()
def _insert_submission(conn, account_id, status, created_at_str):
"""Insereaza o submisie de test cu timestamp explicit."""
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, created_at) "
"VALUES (?, ?, ?, '{}', ?)",
(_uid(), account_id, status, created_at_str),
)
def test_consum_lunar_numara_consumed_statuses(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Test Consum", cui="RO1001")
# 3 statusuri consumate
_insert_submission(conn, account_id, "queued", now_str)
_insert_submission(conn, account_id, "sending", now_str)
_insert_submission(conn, account_id, "sent", now_str)
assert monthly_usage(conn, account_id, now) == 3
def test_consum_lunar_exclude_statusuri_blocate(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Test Blocat", cui="RO1002")
# statusuri care NU se numara
for status in ("error", "needs_mapping", "needs_data"):
_insert_submission(conn, account_id, status, now_str)
assert monthly_usage(conn, account_id, now) == 0
def test_consum_lunar_scoped_pe_cont(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
acct_a = create_account(conn, "Cont A", cui="RO1003")
acct_b = create_account(conn, "Cont B", cui="RO1004")
_insert_submission(conn, acct_a, "sent", now_str)
_insert_submission(conn, acct_a, "sent", now_str)
_insert_submission(conn, acct_b, "sent", now_str)
assert monthly_usage(conn, acct_a, now) == 2
assert monthly_usage(conn, acct_b, now) == 1
def test_consum_lunar_luna_trecuta_nu_se_numara(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
account_id = create_account(conn, "Test Luna Trecuta", cui="RO1005")
# Calculam o data din luna trecuta (prima zi a lunii curente - 1 zi)
first_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
last_of_prev_month = first_of_month - timedelta(days=1)
prev_str = last_of_prev_month.strftime("%Y-%m-%d %H:%M:%S")
_insert_submission(conn, account_id, "sent", prev_str)
# luna curenta: 0
assert monthly_usage(conn, account_id, now) == 0
def test_consum_lunar_granita_luna_noua(conn):
"""Submisii la granita intre luni sunt bucketate corect (timp local RO = UTC in container)."""
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Test Granita", cui="RO1006")
# Prima secunda a lunii curente (calculata consistent cu 'localtime' = UTC in container)
first_of_month = now.replace(day=1, hour=0, minute=0, second=1, microsecond=0)
first_str = first_of_month.strftime("%Y-%m-%d %H:%M:%S")
# Ultima secunda a lunii trecute
last_of_prev_month = first_of_month - timedelta(seconds=2)
prev_str = last_of_prev_month.strftime("%Y-%m-%d %H:%M:%S")
_insert_submission(conn, account_id, "sent", first_str) # luna curenta
_insert_submission(conn, account_id, "sent", prev_str) # luna trecuta
_insert_submission(conn, account_id, "sent", now_str) # luna curenta
assert monthly_usage(conn, account_id, now) == 2
def test_consum_lunar_zero_pe_cont_gol(conn):
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
account_id = create_account(conn, "Cont Gol", cui="RO1007")
assert monthly_usage(conn, account_id, now) == 0
def test_consum_lunar_nu_numara_cross_account(conn):
"""Verificare scoping: contul default (id=1) nu influenteaza alt cont."""
from app.plans import monthly_usage
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Cont Izolat", cui="RO1008")
# Inseram pentru contul default (id=1)
_insert_submission(conn, 1, "sent", now_str)
_insert_submission(conn, 1, "sent", now_str)
# Contul nou nu trebuie sa numere al celor de pe id=1
assert monthly_usage(conn, account_id, now) == 0
assert monthly_usage(conn, 1, now) == 2
# ---------------------------------------------------------------------------
# PRD 5.17 enforcement — logica de limita + kill-switch config
# ---------------------------------------------------------------------------
def test_volume_la_limita_exacta(conn):
"""La exact FREE_MONTHLY_LIMIT submissions, usage == limita (nu inca depasit).
Enforcer-ul verifica usage + nr_cerut > limit, deci la usage=60, nr_cerut=1 ->
61 > 60 -> respins; dar usage=60 in sine (inainte de cerere) e valid.
"""
from app.plans import monthly_usage, FREE_MONTHLY_LIMIT
from app.accounts import create_account
now = _now_utc()
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
account_id = create_account(conn, "Test La Limita", cui="RO2001")
for _ in range(FREE_MONTHLY_LIMIT):
_insert_submission(conn, account_id, "queued", now_str)
conn.commit()
usage = monthly_usage(conn, account_id, now)
assert usage == FREE_MONTHLY_LIMIT, (
f"La limita exacta: asteptat {FREE_MONTHLY_LIMIT}, primit {usage}"
)
# Simulam logica enforcer: 1 cerere noua depaseste limita
assert usage + 1 > FREE_MONTHLY_LIMIT, "O cerere noua trebuia sa depaseasca limita"
# La 0 cereri noi: nu depaseste
assert usage + 0 <= FREE_MONTHLY_LIMIT, "La 0 cereri noi, limita nu e depasita"
def test_enforce_plans_config_default_true(monkeypatch):
"""AUTOPASS_ENFORCE_PLANS implicit True — enforcement activ de la deploy.
Decizie user (autoplan 2026-06-28): nu exista conturi legacy, produs in TESTE,
enforcement DUR activ implicit. Kill-switch oprit explicit cand e necesar.
"""
from app.config import Settings
# Creem Settings fresh (fara env var setata) -> default True
monkeypatch.delenv("AUTOPASS_ENFORCE_PLANS", raising=False)
s = Settings()
assert s.enforce_plans is True, (
"AUTOPASS_ENFORCE_PLANS trebuia sa fie True implicit (enforcement activ din start)"
)
def test_enforce_plans_kill_switch_false(monkeypatch):
"""AUTOPASS_ENFORCE_PLANS=false dezactiveaza enforcement."""
from app.config import Settings
monkeypatch.setenv("AUTOPASS_ENFORCE_PLANS", "false")
s = Settings()
assert s.enforce_plans is False

View File

@@ -148,6 +148,8 @@ def test_prod_requires_key(env):
conn = get_connection()
try:
key = create_api_key(conn, 1)
# Testul verifica autentificarea, nu planul — tier='pro' ca sa treaca gate-ul API (T4).
conn.execute("UPDATE accounts SET tier='pro' WHERE id=1")
finally:
conn.close()
r2 = c.post("/v1/prezentari", json=_body(), headers={"X-API-Key": key})
@@ -184,7 +186,9 @@ def test_key_account_routes_idempotency(env):
conn = get_connection()
try:
conn.execute("INSERT INTO accounts (id, name) VALUES (2, 'al-doilea')")
# tier='pro' pe ambele conturi — testul verifica idempotenta, nu planuri (T4 PRD 5.17).
conn.execute("UPDATE accounts SET tier='pro' WHERE id=1")
conn.execute("INSERT INTO accounts (id, name, tier) VALUES (2, 'al-doilea', 'pro')")
k1 = create_api_key(conn, 1)
k2 = create_api_key(conn, 2)
finally:

View File

@@ -370,3 +370,84 @@ def test_token_critic_in_tema_parametrizat(client, tema, token):
f"Componentele cu var({token}) vor arata gresit pe aceasta tema. "
f"Adauga '{token}:<valoare>;' in blocul CSS al temei '{tema}'."
)
# ── US-001 PRD 5.16: Stiva font sistem standard web ───────────────────────────
def test_font_stack_system_in_base(client):
"""T-E2 (PRD 5.16): base.html DEFINESTE --font-ui si --font-mono in :root
si body foloseste var(--font-ui). Niciun @font-face IBM Plex nu mai exista."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "--font-ui" in html, "Token --font-ui lipseste din :root (US-001 PRD 5.16)"
assert "--font-mono" in html, "Token --font-mono lipseste din :root (US-001 PRD 5.16)"
assert "var(--font-ui)" in html, "body nu foloseste var(--font-ui) (US-001 PRD 5.16)"
assert "@font-face" not in html, \
"@font-face inca prezent in base.html — sterge toate regulile IBM Plex (US-001 PRD 5.16)"
def test_zero_referinte_static_fonts(client):
"""T-E1 (PRD 5.16): nicio referinta /static/fonts/ in template-urile randate de app.
Toate literalele 'IBM Plex Sans' si 'IBM Plex Mono' sunt eliminate."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "/static/fonts/" not in html, \
"Referinta /static/fonts/ inca prezenta in HTML randat — @font-face nestersi complet"
assert "IBM Plex Sans" not in html, \
"Literalul 'IBM Plex Sans' inca prezent in HTML — inlocuieste cu var(--font-ui)"
assert "IBM Plex Mono" not in html, \
"Literalul 'IBM Plex Mono' inca prezent in HTML — inlocuieste cu var(--font-mono)"
def test_landing_fara_font_face_ibm_plex():
"""T-E1 (PRD 5.16): landing.html nu contine @font-face IBM Plex si niciun
literal 'IBM Plex Sans' sau 'IBM Plex Mono' ca font primary."""
landing = Path(__file__).parent.parent / "app" / "web" / "templates" / "landing.html"
assert landing.exists(), f"landing.html negasit la {landing}"
content = landing.read_text(encoding="utf-8")
assert "@font-face" not in content, \
"@font-face inca in landing.html — sterge toate regulile IBM Plex (US-008 PRD 5.16)"
assert "IBM Plex Sans" not in content, \
"Literal 'IBM Plex Sans' inca in landing.html — inlocuieste cu var(--font-ui)"
assert "IBM Plex Mono" not in content, \
"Literal 'IBM Plex Mono' inca in landing.html — inlocuieste cu var(--font-mono)"
# ── US-002 PRD 5.16: Scala tipografica ────────────────────────────────────────
def test_tokeni_scala_fs_definiti(client):
"""US-002 (PRD 5.16): tokenurile de scala tipografica --fs-xs..--fs-3xl si
--lh-tight/--lh-body sunt definiti in :root."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
tokeni = [
"--fs-xs", "--fs-sm", "--fs-base", "--fs-md",
"--fs-lg", "--fs-xl", "--fs-2xl", "--fs-3xl",
"--lh-tight", "--lh-body",
]
for tok in tokeni:
assert tok in html, f"Token {tok} lipseste din :root (US-002 PRD 5.16)"
# ── US-011 PRD 5.16: Selector tema pill cu eticheta ───────────────────────────
def test_selector_tema_are_eticheta(client):
"""US-011 (PRD 5.16): butonul de tema este un pill cu clasa .tema-btn,
contine .tema-icon si #tema-label (eticheta vizibila a temei curente)."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "tema-btn" in html, \
"Clasa .tema-btn lipseste din HTML — butonul de tema nu e pill (US-011 PRD 5.16)"
assert "tema-icon" in html, \
".tema-icon lipseste — iconita temei nu e separat de eticheta (US-011 PRD 5.16)"
assert 'id="tema-label"' in html, \
'#tema-label lipseste — eticheta temei nu e prezenta in pill (US-011 PRD 5.16)'

View File

@@ -544,3 +544,316 @@ def test_repune_select_afiseaza_denumirea(client):
assert "AAA — Schimb ulei motor" in html, (
f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}"
)
# ============================================================================= #
# Teste noi 5.16: US-004 (denumiri picker), US-005 (add_extra), #
# US-006 (save picker fara buton), T-E3 (by-index), T-D1/T-E5, T-C1/T-E4 #
# ============================================================================= #
def test_picker_flat_arata_cod_si_denumire(client):
"""US-004 (5.16): picker plat afiseaza 'cod — denumire', nu doar codul.
RED: _chips_prestatii.html:147 afiseaza doar {{ n.cod_prestatie }};
modul operatii (:101) afiseaza deja 'cod — nume'. Fix: uniformizare.
"""
acct = _create_account_user("picker.flat.denu@test.com")
_login(client, "picker.flat.denu@test.com")
_seed_cod("FRN1", "Sistem de franare")
# Submission flat: fara cod_op_service (mod plat)
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0US4001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [], # mod plat: fara operatii cu cod_op_service
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
# Optiunea trebuie sa arate 'FRN1 — Sistem de franare', nu doar 'FRN1'
assert "FRN1 — Sistem de franare" in resp.text, (
f"Picker plat nu arata denumirea: "
f"{resp.text[resp.text.find('FRN1'):resp.text.find('FRN1')+80] if 'FRN1' in resp.text else 'FRN1 absent'}"
)
def test_adauga_cod_extra_in_mod_operatii(client):
"""US-005 (5.16): in mod operatii, actiunea add_extra adauga un cod RAR liber.
RED: post_form_chips nu are actiunea 'add_extra' -> chips_action ignorata.
"""
acct = _create_account_user("add.extra.ops@test.com")
_login(client, "add.extra.ops@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
# Chips stare: 1 operatie deja mapata (mod ops) → _has_ops = True
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"], # chip existent (op mapata)
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei motor"],
"chips_action": "add_extra",
"chips_add_cod_flat": "FRN1", # codul extra de adaugat
},
)
assert resp.status_code == 200, resp.text[:300]
# FRN1 trebuie sa apara in raspuns (chip extra adaugat)
assert "FRN1" in resp.text, (
f"Codul extra FRN1 nu a fost adaugat in mod operatii: {resp.text[:300]}"
)
# OE-1 trebuie sa ramana (chip original neatins)
assert "OE-1" in resp.text, f"Chip original OE-1 disparut: {resp.text[:300]}"
def test_extra_cod_persistat_la_salvare(client):
"""US-005 (5.16): codul extra adaugat via form-chips e salvat la /corecteaza.
Simulam starea form dupa add_extra: hidden inputs pentru op mapata (OE-1)
+ hidden inputs pentru chip extra flat (FRN1, fara op_service).
"""
acct = _create_account_user("extra.persist@test.com")
_login(client, "extra.persist@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare")
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
"WVWZZZ1JZXW0XP001",
[("SchimbUlei", "Schimb ulei motor")],
))
csrf = _csrf(client)
# Form state dupa add_extra: op mapata (idx=0, OE-1) + chip extra flat (idx=1, FRN1)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", "FRN1"], # OE-1 pt op, FRN1 chip extra
"chip_op_service": ["SchimbUlei", ""], # idx 0 are op_service, idx 1 nu
"chip_denumire": ["Schimb ulei motor", ""],
},
)
assert resp.status_code == 200, resp.text[:300]
r = _row(sid)
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
prestatii = _payload_json(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 (op mapata) lipsa: {prestatii}"
assert "FRN1" in coduri, f"FRN1 (chip extra) lipsa: {prestatii}"
def test_extra_cod_validat_nomenclator(client):
"""US-005 (5.16): add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899).
RED: actiunea add_extra nu exista; dupa fix, cod invalid nu se adauga.
"""
acct = _create_account_user("extra.valid@test.com")
_login(client, "extra.valid@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# add_extra cu cod INVALID (XX-99 nu e in nomenclator)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "XX-99", # cod necunoscut
},
)
assert resp.status_code == 200
html = resp.text
# XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99)
import re as _re
hidden_xx99 = _re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="XX-99"', html)
assert hidden_xx99 is None, (
f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}"
)
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
"""US-006 (5.16): codul ales in picker flat se aplica la /corecteaza fara a apasa '+'.
RED: post_corectie_trimitere citeste form.getlist('cod_prestatie') (hidden inputs)
dar ignora 'chips_add_cod_flat' (picker neselectat ca chip) → submission ramane
needs_mapping desi codul e ales.
"""
acct = _create_account_user("picker.save.nobutton@test.com")
_login(client, "picker.save.nobutton@test.com")
_seed_cod("OE-1", "Schimb ulei motor")
# Submission flat fara prestatii
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0PS001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
csrf = _csrf(client)
# Browser trimite chips_add_cod_flat=OE-1 (ales in picker) dar FARA hidden cod_prestatie
# (userul nu a apasat '+' sa promoveze selectia intr-un chip).
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"chips_add_cod_flat": "OE-1", # ales in picker, ne-aprobat prin '+'
# NU exista 'cod_prestatie' in form (zero hidden chips)
},
)
assert resp.status_code == 200, resp.text[:300]
r = _row(sid)
assert r["status"] == "queued", (
f"Codul ales in picker trebuia sa se aplice la salvare fara '+': status={r['status']}"
)
prestatii = _payload_json(sid)["prestatii"]
coduri = [p.get("cod_prestatie") for p in prestatii]
assert "OE-1" in coduri, f"OE-1 (ales in picker) lipsa din prestatii: {prestatii}"
def test_salvare_fara_chip_explicit_nu_e_no_op(client):
"""US-006 (5.16): o trimitere needs_mapping cu cod ales in picker nu ramane no-op.
Complementar cu test_cod_ales_in_picker_se_salveaza_fara_buton_add: verifica
explicit ca statusul se schimba (nu ramane needs_mapping).
"""
acct = _create_account_user("noop.previne@test.com")
_login(client, "noop.previne@test.com")
_seed_cod("FRN1", "Sistem de franare")
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0NP001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
old_status = _row(sid)["status"]
assert old_status == "needs_mapping"
csrf = _csrf(client)
resp = client.post(
f"/trimitere/{sid}/corecteaza",
data={
"csrf_token": csrf,
"chips_add_cod_flat": "FRN1",
},
)
assert resp.status_code == 200
new_status = _row(sid)["status"]
assert new_status != "needs_mapping", (
f"Salvarea cu cod ales in picker trebuia sa nu fie no-op: status ramas {new_status}"
)
assert new_status == "queued", f"status asteptat queued, got {new_status}"
def test_picker_by_index_op2_nu_op1(client):
"""T-E3 (5.16): codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1.
Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1
actualizeaza chips[1] (op#2), nu chips[0] (op#1).
"""
acct = _create_account_user("byindex.op2@test.com")
_login(client, "byindex.op2@test.com")
_seed_cod("OE-1", "Schimb ulei")
_seed_cod("FRN1", "Sistem de franare")
csrf = _csrf(client)
# Chips: op#1 (idx=0) deja mapata cu OE-1, op#2 (idx=1) nemapata (cod gol)
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata)
"chip_op_service": ["Op-A", "Op-B"],
"chip_denumire": ["Prima", "A doua"],
"chips_action": "add",
"chips_add_op_index": "1", # adauga pe op#2 (idx=1)
"chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1
},
)
assert resp.status_code == 200, resp.text[:300]
html = resp.text
import re as _re
hidden_vals = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="([^"]*)"', html)
assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}"
assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}"
# By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1)
oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1
frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1
assert oe1_pos < frn1_pos, (
f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}"
)
def test_empty_state_picker_nomenclator_gol(client):
"""T-D1/T-E5 (5.16): empty-state vizibil cand nomenclatorul e gol.
RED: {% if nomenclator_rar %} fara {% else %} -> silentios; un rand needs_mapping
fara nomenclator nu are nicio cale de a adauga cod (nereparabil silentios).
GREEN: div.chips-nom-gol vizibil.
"""
acct = _create_account_user("empty.nom@test.com")
_login(client, "empty.nom@test.com")
# Golim nomenclatorul: seed_nomenclator_if_empty populeaza la initializare DB;
# testul simuleaza cazul extrem cand tabla e goala (post-update, inainte de re-seed).
from app.db import get_connection as _gconn
_c = _gconn()
_c.execute("DELETE FROM nomenclator_rar")
_c.commit()
_c.close()
sid = _insert(acct, status="needs_mapping", payload={
"vin": "WVWZZZ1JZXW0EN001",
"nr_inmatriculare": "B100AAA",
"data_prestatie": "2026-06-10",
"odometru_final": "50000",
"prestatii": [],
})
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
assert "chips-nom-gol" in resp.text, (
f"Empty state 'chips-nom-gol' lipsa cand nomenclatorul e gol: {resp.text[resp.text.find('chips'):resp.text.find('chips')+200] if 'chips' in resp.text else resp.text[:500]}"
)
def test_add_extra_semnal_vizibil_cod_invalid(client):
"""T-C1/T-E4 (5.16): add_extra cu cod invalid da semnal vizibil (nu esua silentios).
RED: actiunea add_extra nu exista → nu exista niciun semnal.
GREEN: div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
"""
acct = _create_account_user("extra.err.signal@test.com")
_login(client, "extra.err.signal@test.com")
_seed_cod("OE-1", "Schimb ulei")
csrf = _csrf(client)
# add_extra cu cod necunoscut in nomenclator
resp = client.post(
"/form-chips",
data={
"csrf_token": csrf,
"cod_prestatie": ["OE-1"],
"chip_op_service": ["SchimbUlei"],
"chip_denumire": ["Schimb ulei"],
"chips_action": "add_extra",
"chips_add_cod_flat": "XX-99", # cod inexistent
},
)
assert resp.status_code == 200
assert "chips-extra-error" in resp.text, (
f"Semnalul 'chips-extra-error' lipsa pentru cod invalid: {resp.text[:300]}"
)

View File

@@ -0,0 +1,195 @@
"""Teste E2E enforcement plan pe canalul web de import (PRD 5.17 T3).
Verifica ca limita de volum (60/luna free) e respectata si pe canalul web
(web_confirma_import in routes.py), nu doar pe canalul API.
"""
from __future__ import annotations
import csv
import io
import os
import re
import tempfile
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Fixture client web izolat (WEB_AUTH_REQUIRED=false -> fara login, cont 1)
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(monkeypatch):
"""Client cu DB izolata, WEB_AUTH_REQUIRED=false (dev — fara login necesar)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "web-e2e-plan.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
reset_cache()
# ---------------------------------------------------------------------------
# Utilitare
# ---------------------------------------------------------------------------
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), delimiter=sep)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue().encode("utf-8")
def _seed_nomenclator_si_mapare(cod_prestatie: str = "OE-1", cod_op: str = "OP-WEB-PLAN") -> None:
"""Semeaza nomenclatorul RAR si o mapare operatie->cod (necesare pentru randuri ok)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?,?)",
(cod_prestatie, "Operatie test plan"),
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) VALUES (1,?,?,1)",
(cod_op, cod_prestatie),
)
conn.commit()
finally:
conn.close()
def _insert_60_submissions_luna() -> None:
"""Insereaza 60 submissions queued in luna curenta pentru contul 1 (la limita free)."""
from app.db import get_connection
conn = get_connection()
try:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
for i in range(60):
conn.execute(
"INSERT INTO submissions "
"(idempotency_key, account_id, status, payload_json, created_at) "
"VALUES (?, 1, 'queued', '{}', ?)",
(f"web-vol60-{i}-{os.urandom(4).hex()}", now_str),
)
conn.commit()
finally:
conn.close()
def _upload_preview_si_commit(client: TestClient, rows: list[dict]): # type: ignore[return]
"""Parcurge fluxul web: upload -> mapare coloane (daca e necesar) -> confirma.
Intoarce (import_id, raspuns_confirma). Presupune nomenclatorul si maparea semanate.
"""
data = _csv_bytes(rows)
r = client.post(
"/_import/upload",
files={"file": ("plan_test.csv", io.BytesIO(data), "text/csv")},
)
assert r.status_code == 200, f"Upload esuat: {r.text[:300]}"
m = re.search(r"/_import/(\d+)/", r.text)
assert m, f"import_id negasit in raspunsul de upload: {r.text[:400]}"
iid = int(m.group(1))
if f"/_import/{iid}/mapare-coloane" in r.text:
r2 = client.post(
f"/_import/{iid}/mapare-coloane",
data={
"colname": ["VIN", "Nr", "Data", "KM", "Operatie"],
"canon": ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final", "operatie"],
"format_data": "YYYY-MM-DD",
},
)
assert r2.status_code == 200, f"Mapare coloane esuata: {r2.text[:300]}"
# GET preview pentru n_ok
rp = client.get(f"/_import/{iid}/preview")
assert rp.status_code == 200, f"Preview esuat: {rp.text[:300]}"
m_ok = re.search(r'id="n-confirmat"[^>]*?value="(\d+)"', rp.text)
n_ok = int(m_ok.group(1)) if m_ok else len(rows)
r_conf = client.post(
f"/_import/{iid}/confirma",
data={
"csrf_token": "",
"n_confirmat": str(n_ok),
"confirmed_by": "test-plan@autopass.ro",
},
)
return iid, r_conf
# Date CSV: un singur rand ok
_ROWS_PLAN_WEB = [
{
"VIN": "WVWZZZ1KZAW700001",
"Nr": "B700TST",
"Data": "2026-06-15",
"KM": "70000",
"Operatie": "OP-WEB-PLAN",
},
]
# ---------------------------------------------------------------------------
# Test T3 — volum pe canalul web
# ---------------------------------------------------------------------------
def test_free_peste_60_respins_import_web(client):
"""Canal WEB de import: free la 60/60 → commit respins cu mesaj de limita plan.
T3 PRD 5.17: enforcement volum pe canalul web (web_confirma_import in routes.py).
Contul 1 e pe tier=free, fara trial, la 60/60 prestatii in luna curenta.
Commit-ul unui lot nou trebuie respins (intregul lot, nu partial) cu mesaj clar.
"""
from app.db import get_connection
# Seteaza contul 1 (implicit web in dev mode) pe free fara trial
conn = get_connection()
try:
conn.execute("UPDATE accounts SET tier='free', trial_until=NULL WHERE id=1")
conn.commit()
finally:
conn.close()
# Seed nomenclator si mapare operatie->cod
_seed_nomenclator_si_mapare()
# Insereaza 60 submissions (la limita)
_insert_60_submissions_luna()
# Parcurge fluxul web pana la commit
_iid, r_conf = _upload_preview_si_commit(client, _ROWS_PLAN_WEB)
assert r_conf.status_code == 200, ( # type: ignore[union-attr]
f"Commit trebuia sa intoarca 200 HTML (nu 5xx): {r_conf.status_code}" # type: ignore[union-attr]
)
# Raspunsul HTML trebuie sa contina mesajul de limita de plan
html = r_conf.text.lower() # type: ignore[union-attr]
assert ("limita" in html or "gratuit" in html or "60" in html), (
f"Mesajul de limita plan lipseste din raspunsul HTML al commit-ului:\n{r_conf.text[:600]}" # type: ignore[union-attr]
)
# Verifica ca nu s-au creat submissions noi (lotul a fost respins total)
from app.plans import monthly_usage
conn2 = get_connection()
try:
usage = monthly_usage(conn2, 1, datetime.now(timezone.utc))
finally:
conn2.close()
assert usage == 60, (
f"Lotul respins nu trebuia sa adauge submissions: asteptat usage=60, primit {usage}"
)

View File

@@ -291,3 +291,94 @@ def test_import_forms_pastreaza_csrf(client):
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"
# ---------------------------------------------------------------------------
# US-013 Teste: import colapsat + tokeni scala + pill-uri cu dot (PRD 5.16)
# ---------------------------------------------------------------------------
def test_import_colapsat_implicit(client):
"""Pe Acasa (first-run, fara trimiteri), sectiunea de import e deschisa implicit.
La first-run (are_trimiteri=False), <details> trebuie sa aiba atributul `open`.
Summary-ul trebuie sa contina textul slim 'Importa fisier' (bara colapsabila).
Verifica si ca <details id="import-details"> este prezent pe pagina principala.
"""
r = client.get("/")
assert r.status_code == 200
text = r.text
# Elementul <details> trebuie sa fie prezent
assert 'id="import-details"' in text, \
"Elementul <details id='import-details'> lipseste de pe pagina principala"
# La first-run (nu exista trimiteri), details trebuie sa fie deschis (atribut open)
assert 'id="import-details" open' in text, \
"La first-run, <details id='import-details'> trebuie sa aiba atributul 'open'"
# Textul summary trebuie sa contina 'Importa fisier' (bara slim colapsabila)
assert "Importa fisier" in text, \
"Textul 'Importa fisier' nu a fost gasit in summary-ul sectiunii de import"
def test_wizard_foloseste_scala_tokeni(client):
"""Fragmentele wizard-ului de import folosesc tokeni var(--fs-*) in loc de px hardcodat.
Verifica ca fragmentul de mapare coloane (_mapcoloane.html) si cel de upload
(_upload.html) contin referinte la tokenii de scala --fs-* in inline styles,
nu font-size hardcodat in px sub 12px.
"""
# Fragment upload (/_import/reset) → _upload.html
r_upload = client.get("/_import/reset")
assert r_upload.status_code == 200
upload_text = r_upload.text
# Tokenii trebuie sa apara in inline styles
assert "var(--fs-" in upload_text, \
"Tokenii var(--fs-*) nu au fost gasiti in fragmentul de upload (_upload.html)"
# Fragment mapare coloane → _mapcoloane.html
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
map_text = r_map.text
# Mapcoloane trebuie sa contina tokeni
assert "var(--fs-" in map_text, \
"Tokenii var(--fs-*) nu au fost gasiti in fragmentul mapare coloane (_mapcoloane.html)"
# Verifica ca nu exista font-size sub 12px hardcodat in fragmentele wizard
import re
for fragment_text, fragment_name in [(upload_text, "upload"), (map_text, "mapcoloane")]:
for size_str in re.findall(r'font-size:\s*(\d+)px', fragment_text):
size = int(size_str)
assert size >= 12, \
f"font-size:{size}px sub 12px gasit in fragmentul {fragment_name} — trebuie var(--fs-*)"
def test_preview_stari_pill_dot(client):
"""Pill-urile de stare din preview contin un dot consistent cu designul 5.16.
Verifica ca pill-urile din tabelul de preview si din rezumatul de stari contin
un element dot (span cu border-radius:99px ca inline style), consistent cu stripul
slim si cu designul 5.16 (dot + text, nu text gol).
Eticheta umana: din STARI_PREVIEW ('Gata de trimis', 'Cod RAR lipsa' etc.) — nicio
eticheta noua.
"""
_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 "confirm-form" in text or "Preview" in text, \
"Fragmentul de preview nu a fost randat"
# Pill-urile de stare trebuie sa contina un dot (span cu border-radius:99px)
assert "border-radius:99px" in text, \
"Dot-ul (border-radius:99px) nu a fost gasit in pill-urile de stare din preview"
# Etichetele umane din STARI_PREVIEW trebuie sa fie prezente (nicio eticheta noua)
# 'Gata de trimis' apare in rezumatul de stari (pill) sau in tabelul de randuri
assert "Gata de trimis" in text or "Cod RAR lipsa" in text or "Verifica valori" in text, \
"Etichetele umane din STARI_PREVIEW nu au fost gasite in preview"

View File

@@ -224,8 +224,8 @@ def test_logo_linkeaza_acasa(client):
"In prezent logo-ul nu e un link."
)
# Titlul "Gateway RAR AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
# (PRD AC: Logo-ul ROMFAST + titlul linkeaza la /)
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?Gateway RAR AUTOPASS', header_html, re.DOTALL), (
"Titlul 'Gateway RAR AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
# Titlul "ROMFAST AUTOPASS" trebuie sa fie si el in interiorul unui <a href="/">
# (PRD AC US-010: Logo-ul ROMFAST + titlul linkeaza la /; titlul a fost redenumit in 5.16)
assert re.search(r'<a\b[^>]*href="/"[^>]*>.*?ROMFAST AUTOPASS', header_html, re.DOTALL), (
"Titlul 'ROMFAST AUTOPASS' trebuie sa fie intr-un <a href='/'> in header."
)

View File

@@ -738,3 +738,141 @@ def test_strip_sanatate_fara_hex_hardcodat():
f"Hex literal de culoare in _status.html — strip sanatate va arata gresit pe "
f"tema hartie (luminoasa) / light. Folositi var(--token). Gasite: {hex_in_culori}"
)
# ============================================================
# PRD 5.16 US-010: Titlu ROMFAST AUTOPASS + account_name in antet
# ============================================================
def test_titlu_romfast_autopass(client):
"""US-010 (PRD 5.16): titlul din antet si tag-ul <title> sunt 'ROMFAST AUTOPASS',
nu 'Gateway RAR AUTOPASS'."""
_create_account_user("titlutest@test.com", name="Service Titlu")
_login(client, "titlutest@test.com")
html = client.get("/?tab=acasa").text
assert "ROMFAST AUTOPASS" in html, \
"Titlul 'ROMFAST AUTOPASS' lipseste din antet (US-010 PRD 5.16)"
assert "Gateway RAR AUTOPASS" not in html, \
"Titlul vechi 'Gateway RAR AUTOPASS' inca prezent — inlocuieste cu 'ROMFAST AUTOPASS'"
def test_header_arata_nume_service_logat(client):
"""US-010 (PRD 5.16): cand utilizatorul e logat, antetul afiseaza numele service-ului
(accounts.name) ca sub-titlu cu clasa .h-sub."""
_create_account_user("numeservice@test.com", name="Service Auto Cluj SRL")
_login(client, "numeservice@test.com")
html = client.get("/?tab=acasa").text
assert "Service Auto Cluj SRL" in html, \
"Numele service-ului nu apare in antet (US-010 PRD 5.16) — verifica .h-sub"
assert "h-sub" in html, \
"Clasa .h-sub lipseste din antet (US-010 PRD 5.16) — sub-titlul account_name lipseste"
def test_login_branded_nu_schelet(client):
"""US-010 (PRD 5.16): /login are layout 2-coloane branduit cu clasa .login-shell,
titlul 'ROMFAST AUTOPASS', si formular cu POST /login (CSRF intact)."""
resp = client.get("/login")
assert resp.status_code == 200
html = resp.text
assert "login-shell" in html, \
"Clasa .login-shell lipseste din /login (US-010 PRD 5.16) — layout 2-coloane nenimplementat"
assert "login-aside" in html, \
"Clasa .login-aside lipseste — coloana stanga de brand lipseste (US-010)"
assert "ROMFAST AUTOPASS" in html, \
"Titlul 'ROMFAST AUTOPASS' lipseste din /login (US-010 PRD 5.16)"
# Formular intact: POST /login cu csrf_token
assert 'action="/login"' in html, "Actiunea formularului /login s-a schimbat — CSRF route invalida"
assert 'name="csrf_token"' in html, "csrf_token lipseste din formular — securitate compromisa"
assert 'name="parola"' in html, "Campul 'parola' lipseste din formular"
# ============================================================
# PRD 5.17 T7 (US-007): landing copy — limita 60 + trial Pro
# PRD 5.16 US-012: Autentificare → /login
# ============================================================
def _citeste_landing() -> str:
"""Returneaza continutul landing.html (template static; variabilele Jinja2 nu
afecteaza copy-ul de limita/plan/buton verificat mai jos)."""
from pathlib import Path
p = Path(__file__).parent.parent / "app" / "web" / "templates" / "landing.html"
assert p.exists(), f"landing.html negasit la {p}"
return p.read_text(encoding="utf-8")
def test_landing_limita_60():
"""5.17 T7 (US-007): limita planului Gratuit este 60 de prestatii/luna in landing,
nu 100. Verifica meta description, announce bar, hero badge, cardul Gratuit si
CTA-ul final."""
html = _citeste_landing()
assert "100 de prestații" not in html, \
"'100 de prestații' inca prezent in landing — limita trebuie sa fie 60 (5.17 T7)"
assert "100 prestații" not in html, \
"'100 prestații' inca prezent in landing — limita trebuie sa fie 60 (5.17 T7)"
assert "60 de prestații" in html, \
"'60 de prestații' lipseste din landing — verifica meta, announce bar, cardul Gratuit (5.17 T7)"
assert "60 prestații" in html, \
"'60 prestații' lipseste din hero badge in landing (5.17 T7)"
assert "60 de prezentări" in html, \
"'60 de prezentări' lipseste din CTA-ul final al landing-ului (5.17 T7)"
def test_landing_trial_pro_nu_premium():
"""5.17 T7 (US-007): trial-ul de 30 de zile este pe Pro, NU pe Premium.
Verifica sectiunea PRICING (subtitle) si sectiunea AUTH (lista beneficii)."""
html = _citeste_landing()
assert "Pro gratuit 30 de zile" in html, \
"'Pro gratuit 30 de zile' lipseste din landing — verifica sectiunile PRICING + AUTH (5.17 T7)"
assert "Premium gratuit 30 de zile" not in html, \
"'Premium gratuit 30 de zile' inca in landing — trial-ul e pe Pro, nu Premium (5.17 T7)"
def test_landing_autentificare_link_login():
"""5.16 US-012: butonul 'Autentificare' din header-ul landing este un link <a href='/login'>
cu clasa auth-login-link, NU un buton care deschide modalul de login.
CSS-ul responsive (.lp-hactions) trebuie sa foloseasca noul selector, nu cel vechi."""
html = _citeste_landing()
# Link real catre /login in header (cu clasa de identificare)
assert 'href="/login"' in html, \
"href='/login' lipseste din landing — 'Autentificare' din header trebuie sa fie link (5.16 US-012)"
assert "auth-login-link" in html, \
"Clasa auth-login-link lipseste — header 'Autentificare' nu a fost convertit la <a> (5.16 US-012)"
# CSS-ul responsive ascunde linkul pe <430px prin noul selector (nu cel vechi cu atribute)
assert "a.auth-login-link" in html, \
"Selectorul CSS 'a.auth-login-link' lipseste — CSS responsive neactualizat (5.16 US-012)"
# Selectorul CSS vechi cu [data-act="auth"][data-tab="login"] nu mai exista in CSS
assert '[data-act="auth"][data-tab="login"]' not in html, \
"Selectorul CSS vechi [data-act='auth'][data-tab='login'] inca prezent (5.16 US-012)"
def test_contoare_desktop_ascunse_pe_mobil_fara_inline_display():
"""US-002 (PRD 5.16): pe <=560px se vad DOAR contoarele compacte, nu si cele 5 carduri mari.
Regresie prinsa la VERIFY E2E (390px): un inline `style="display:flex"` pe `.contoare-desktop`
batea regula `@media (max-width:560px) { .contoare-desktop { display:none } }` (inline > stylesheet)
-> contoare DUPLICATE pe mobil. Lock: `display:flex` sta in CSS (nu inline pe element), iar media
query-ul ascunde cardurile mari pe mobil.
"""
from pathlib import Path
tdir = Path(__file__).parent.parent / "app" / "web" / "templates"
base = (tdir / "base.html").read_text(encoding="utf-8")
status = (tdir / "_status.html").read_text(encoding="utf-8")
# _status.html: containerul de carduri NU mai are inline display (altfel bate media query-ul).
assert 'class="contoare-desktop" style="display:flex' not in status, (
"containerul .contoare-desktop are inline display:flex -> media query-ul nu-l mai poate ascunde pe mobil"
)
# base.html: regula CSS default (display:flex) + ascunderea pe <=560px.
assert re.search(r"\.contoare-desktop\s*\{[^}]*display:\s*flex", base), (
"lipseste regula CSS .contoare-desktop { display:flex } in base.html"
)
assert re.search(r"\.contoare-desktop\s*\{[^}]*display:\s*none", base), (
"lipseste ascunderea .contoare-desktop { display:none } (media <=560px) in base.html"
)

View File

@@ -81,7 +81,10 @@ def client(monkeypatch):
# ---------------------------------------------------------------------------
def test_status_are_bife_verzi_cand_totul_ok(client):
"""Worker viu + RAR login recent -> glifa verde ✓ + text 'declaratiile curg normal'."""
"""US-003 PRD 5.16: worker viu + RAR login recent -> strip-sanatate in DOM dar ASCUNS (hidden).
Banda rosie apare DOAR cand BLOCAT. Starea OK e indicata de dot-ul verde din antet (base.html).
Elementul id=strip-sanatate ramane in DOM pentru compatibilitate (nu dispare complet).
"""
_create_account_user("bifeok@test.com")
_login(client, "bifeok@test.com", "parolasecreta10")
@@ -91,12 +94,11 @@ def test_status_are_bife_verzi_cand_totul_ok(client):
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
# Glifa accesibila ✓ (nu doar culoare)
assert "&#10003;" in html, f"Lipseste glifa ✓ cand totul e ok. HTML: {html[:600]}"
# US-003 D6: strip unificat (nu bife individuale worker/RAR)
assert "curg normal" in html.lower(), (
f"Textul 'curg normal' din strip sanatate lipseste. HTML: {html[:600]}"
)
# US-003: elementul strip-sanatate e prezent in DOM dar ascuns cand totul e ok
assert 'id="strip-sanatate"' in html, f"id=strip-sanatate lipseste complet din fragment. HTML: {html[:600]}"
# Cand OK, banda nu trebuie sa afiseze ✗ (eroare) — ✓ nu mai apare (banda e ascunsa)
assert "&#10007;" not in html, \
f"Glifa ✗ (eroare) apare cand starea e ok — banda e gresit afisata. HTML: {html[:600]}"
def test_status_are_bife_rosii_cand_worker_oprit(client):
@@ -183,7 +185,8 @@ def test_strip_rosu_worker_oprit(client):
def test_trei_contoare_card(client):
"""US-003: fragment status contine exact 3 carduri .contor-card (In coada / Trimise / De corectat)."""
"""US-002 PRD 5.16: fragment status contine 5 carduri .contor-card separate:
Total / Luna asta / Azi / In coada / De corectat."""
_create_account_user("treicont@test.com")
_login(client, "treicont@test.com", "parolasecreta10")
@@ -192,12 +195,15 @@ def test_trei_contoare_card(client):
html = resp.text
count = html.count("contor-card")
assert count >= 3, (
f"Trebuie minim 3 elemente contor-card in fragment, gasit: {count}. HTML: {html[:800]}"
assert count >= 5, (
f"Trebuie minim 5 elemente contor-card (US-002 PRD 5.16: Total/Luna/Azi/Coada/Corectat), "
f"gasit: {count}. HTML: {html[:800]}"
)
# Etichete asteptate
# Etichete asteptate (US-002 PRD 5.16: 5 carduri separate)
assert "Total" in html, "Eticheta 'Total' lipseste din contoare (US-002 PRD 5.16)."
assert "Luna asta" in html, "Eticheta 'Luna asta' lipseste din contoare (US-002 PRD 5.16)."
assert "Azi" in html, "Eticheta 'Azi' lipseste din contoare (US-002 PRD 5.16)."
assert "In coada" in html, "Eticheta 'In coada' lipseste din contoare."
assert "Trimise" in html, "Eticheta 'Trimise' lipseste din contoare."
assert "De corectat" in html, "Eticheta 'De corectat' lipseste din contoare."
@@ -250,6 +256,98 @@ def test_fara_bara_veche(client):
)
def test_banda_apare_doar_cand_blocat(client):
"""US-003 (PRD 5.16): banda rosie completa apare NUMAI cand BLOCAT.
Cand totul e ok, strip-sanatate are atributul 'hidden' (ascuns, nu disparut).
Cand worker e oprit, strip-sanatate NU are 'hidden' (e vizibil, rosu).
"""
_create_account_user("bandablocat@test.com")
_login(client, "bandablocat@test.com", "parolasecreta10")
# Stare OK: strip ascuns
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html_ok = resp.text
# Cand OK, elementul e ascuns
assert 'id="strip-sanatate"' in html_ok, "strip-sanatate lipseste din DOM cand totul e ok"
assert "&#10007;" not in html_ok, "Glifa eroare apare cand sanatate=ok (banda nu trebuie sa fie rosie)"
# Stare BLOCAT: strip vizibil cu glifa ✗
_set_heartbeat(last_beat=None, last_rar_login_ok=None)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html_err = resp.text
assert 'id="strip-sanatate"' in html_err, "strip-sanatate lipseste din DOM cand blocat"
assert "&#10007;" in html_err, "Glifa ✗ lipseste cand BLOCAT (banda trebuie sa fie rosie)"
def test_rar_dot_in_antet_ok(client):
"""US-003 (PRD 5.16): cand logat si sanatate_ok, antetul contine chip-ul RAR cu clasa rar-ok.
Starea ok se vede din header (dot verde pulsant), nu din banda de stare (care e ascunsa).
"""
_create_account_user("rardot@test.com")
_login(client, "rardot@test.com", "parolasecreta10")
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Chip RAR in antet (nu in banda de stare)
assert "rar-chip" in html, "Clasa rar-chip lipseste din HTML (dot RAR in antet, US-003)"
assert "rar-ok" in html, "Clasa rar-ok lipseste — dot verde cand sanatate ok (US-003)"
assert "rar-dot" in html, "Clasa rar-dot lipseste din chip (US-003)"
def test_rar_in_meniu_burger(client):
"""US-003/010 (PRD 5.16): meniul burger contine starea RAR ca prima intrare (RAR online / RAR indisponibil)."""
_create_account_user("rarmeniu@test.com")
_login(client, "rarmeniu@test.com", "parolasecreta10")
now = datetime.now(timezone.utc).isoformat()
_set_heartbeat(last_beat=now, last_rar_login_ok=now)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Meniul burger (cont-menu) contine indicatorul RAR
assert "cont-menu" in html, "Meniu burger (cont-menu) lipseste din HTML"
assert "RAR online" in html or "RAR indisponibil" in html, \
"Starea RAR nu apare in meniu burger (US-003/010)"
# Prima intrare e starea RAR — prezenta class menu-rar-line
assert "menu-rar-line" in html, "Clasa menu-rar-line lipseste din burger (US-003)"
def test_anuleaza_are_data_modal_close(client):
"""US-007 (PRD 5.16): overlay-ul modal si butonul de inchidere au atributul data-modal-close."""
# Butonul si overlay-ul trebuie sa aiba data-modal-close pentru ca handler-ul cu .closest() sa functioneze
# Verificam in baza template-ului base.html (modal e definit acolo, randat pe toate paginile)
# Testam pe dashboard dupa login (unde baza e incarcata)
_create_account_user("modalclose@test.com")
_login(client, "modalclose@test.com", "parolasecreta10")
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "data-modal-close" in html, \
"data-modal-close lipseste din template — modalul nu se poate inchide (US-007)"
def test_modal_close_pe_element_interior(client):
"""US-007 (PRD 5.16): handler-ul modal foloseste .closest('[data-modal-close]') nu
.hasAttribute directe — astfel click pe un element interior al backdrop-ului functioneaza."""
_create_account_user("modalclosest@test.com")
_login(client, "modalclosest@test.com", "parolasecreta10")
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Verificam ca JS-ul foloseste closest, nu hasAttribute
assert "closest('[data-modal-close]')" in html, \
"Handler-ul modal foloseste hasAttribute in loc de closest (US-007) — click pe copil nu va inchide modalul"
def _set_tz_bucuresti(monkeypatch, request):
"""Forteaza TZ=Europe/Bucharest pentru ca modificatorul SQLite 'localtime' sa
rezolve la fusul RO indiferent de TZ-ul runner-ului (CI ruleaza de regula in UTC).
@@ -359,3 +457,227 @@ def test_iarna_nu_bleed_in_ziua_urmatoare(monkeypatch, request):
finally:
conn.close()
get_settings.cache_clear()
# ===========================================================================
# US-006 (PRD 5.17) — Afisaj plan curent: trial / consum / warn / banner
# ===========================================================================
def _set_trial_until(account_id: int, trial_until_str: str | None) -> None:
"""Seteaza direct trial_until pentru un cont (helper de test)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET trial_until=? WHERE id=?",
(trial_until_str, account_id),
)
conn.commit()
finally:
conn.close()
def _insert_submissions_sent(account_id: int, n: int) -> None:
"""Insereaza N submissions sent in luna curenta (helper de test)."""
from app.db import get_connection
import json as _json
conn = get_connection()
try:
for i in range(n):
conn.execute(
"INSERT INTO submissions (account_id, status, payload_json, idempotency_key, created_at) "
"VALUES (?, 'sent', ?, ?, datetime('now'))",
(account_id, _json.dumps({"vin": f"VIN{i:013d}"}), f"key-plan-{account_id}-{i}"),
)
conn.commit()
finally:
conn.close()
def test_afisaj_plan_si_zile_trial(client):
"""US-006: cont in trial Pro -> fragment status arata 'trial N zile ramase'.
Contul nou primeste trial_until=now+30z automat la creare.
"""
acct_id, _ = _create_account_user("trialzile@test.com")
_login(client, "trialzile@test.com", "parolasecreta10")
# trial_until = now + 18 zile + 12h (buffer pt a evita delta.days=17 din timing test)
future = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "Plan: Pro" in html, f"Textul 'Plan: Pro' lipseste in trial. HTML: {html[:800]}"
assert "trial" in html.lower(), f"Cuvantul 'trial' lipseste in starea de trial. HTML: {html[:800]}"
assert "18" in html, f"Numarul de zile (18) nu apare in afisaj. HTML: {html[:800]}"
assert "zile" in html, f"Cuvantul 'zile' lipseste (pluralizare). HTML: {html[:800]}"
def test_afisaj_consum_lunar(client):
"""US-006: cont free (fara trial) -> fragment status arata 'Gratuit · N/60 luna asta'."""
acct_id, _ = _create_account_user("consumlun@test.com")
_login(client, "consumlun@test.com", "parolasecreta10")
# Dezactiveaza trial-ul (cont free pur)
_set_trial_until(acct_id, None)
# Insereaza 5 submissions sent luna asta
_insert_submissions_sent(acct_id, 5)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "Gratuit" in html, f"'Gratuit' lipseste din afisajul de consum. HTML: {html[:800]}"
assert "5" in html, f"Contorul de consum (5) nu apare. HTML: {html[:800]}"
assert "60" in html, f"Limita (60) nu apare in afisajul de consum. HTML: {html[:800]}"
assert "luna asta" in html, f"'luna asta' lipseste din afisajul de consum. HTML: {html[:800]}"
def test_avertizare_aproape_de_limita(client):
"""US-006: >=80% din 60 -> avertizare cu text 'aproape de limita' + culoare warn."""
acct_id, _ = _create_account_user("aproapelim@test.com")
_login(client, "aproapelim@test.com", "parolasecreta10")
_set_trial_until(acct_id, None)
# 50/60 = 83% -> warn
_insert_submissions_sent(acct_id, 50)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "aproape de limita" in html, (
f"Textul 'aproape de limita' lipseste la 50/60. HTML: {html[:800]}"
)
assert "50" in html, f"Contorul 50 nu apare. HTML: {html[:800]}"
# Warn = culoare (var(--warn) in inline style)
assert "var(--warn)" in html or "plan-warn" in html, (
f"Stilul de warn (var(--warn) sau clasa plan-warn) lipseste la aproape-de-limita. HTML: {html[:800]}"
)
def test_limita_atinsa(client):
"""US-006: 60/60 -> text 'limita atinsa'."""
acct_id, _ = _create_account_user("limitaatinsa@test.com")
_login(client, "limitaatinsa@test.com", "parolasecreta10")
_set_trial_until(acct_id, None)
_insert_submissions_sent(acct_id, 60)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "limita atinsa" in html, (
f"Textul 'limita atinsa' lipseste la 60/60. HTML: {html[:800]}"
)
def test_copy_pluralizare_zi_zile(client):
"""US-006: pluralizare RO corecta — 1 zi (nu '1 zile'), 18 zile (nu '18 zi')."""
acct_id, _ = _create_account_user("pluralzile@test.com")
_login(client, "pluralzile@test.com", "parolasecreta10")
# 18 zile: trebuie "18 zile ramase" (buffer 12h pt delta.days determinist)
future_18 = (datetime.now(timezone.utc) + timedelta(days=18, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future_18)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "18 zile" in html, f"'18 zile' lipseste. HTML: {html[:800]}"
assert "18 zi " not in html and "18 zi<" not in html, (
f"'18 zi' (plural gresit) apare in loc de '18 zile'. HTML: {html[:800]}"
)
# 1 zi: trebuie "1 zi ramasa" (singular); buffer 12h
future_1 = (datetime.now(timezone.utc) + timedelta(days=1, hours=12)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future_1)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "1 zi" in html, f"'1 zi' (singular) lipseste la o zi ramasa. HTML: {html[:800]}"
assert "1 zile" not in html, (
f"'1 zile' (plural gresit) apare in loc de '1 zi'. HTML: {html[:800]}"
)
def test_banner_one_time_trial_expirat(client):
"""US-006 T-DES-1: dupa expirarea trial-ului, banner 'Trial Pro expirat' apare in _status.html."""
acct_id, _ = _create_account_user("trialexp@test.com")
_login(client, "trialexp@test.com", "parolasecreta10")
# trial_until in trecut -> trial expirat -> banner one-time
past = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, past)
resp = client.get("/_fragments/status")
assert resp.status_code == 200
html = resp.text
assert "Trial Pro expirat" in html, (
f"Banner 'Trial Pro expirat' lipseste dupa expirarea trial-ului. HTML: {html[:800]}"
)
assert "Gratuit" in html, (
f"Dupa expirarea trial-ului, planul trebuie sa afiseze 'Gratuit'. HTML: {html[:800]}"
)
# Bannerul are buton de dismiss
assert "banner-trial-expirat" in html, (
f"Elementul id=banner-trial-expirat lipseste. HTML: {html[:800]}"
)
def test_cont_arata_plan(client):
"""US-006: tab-ul Cont (/tab=cont) afiseaza planul curent si explicatia de upgrade."""
acct_id, _ = _create_account_user("contplan@test.com")
_login(client, "contplan@test.com", "parolasecreta10")
_set_trial_until(acct_id, None) # free fara trial
resp = client.get("/?tab=cont", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "Plan curent" in html or "sectiune-plan" in html, (
f"Sectiunea 'Plan curent' lipseste din tab-ul Cont. HTML: {html[:1000]}"
)
assert "Gratuit" in html, f"'Gratuit' lipseste din planul afisat in Cont. HTML: {html[:1000]}"
assert "Standard" in html or "Pro" in html, (
f"Optiunile de upgrade (Standard/Pro) lipsesc din sectiunea Plan. HTML: {html[:1000]}"
)
def test_plan_linie_in_burger(client):
"""US-006: meniul burger contine linia de plan (Plan: Gratuit / Pro · trial N zile)."""
acct_id, _ = _create_account_user("burgerplan@test.com")
_login(client, "burgerplan@test.com", "parolasecreta10")
_set_trial_until(acct_id, None) # free fara trial
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
# Meniul burger trebuie sa contina linia de plan
assert "Plan: Gratuit" in html, (
f"'Plan: Gratuit' lipseste din meniu burger. HTML (fragment): {html[html.find('cont-menu'):html.find('cont-menu')+500] if 'cont-menu' in html else html[:500]}"
)
def test_trial_pro_arata_zile_in_burger(client):
"""US-006: cont in trial -> burger arata 'Plan: Pro · trial N zile ramase'."""
acct_id, _ = _create_account_user("burgertrial@test.com")
_login(client, "burgertrial@test.com", "parolasecreta10")
future = (datetime.now(timezone.utc) + timedelta(days=10)).strftime("%Y-%m-%d %H:%M:%S")
_set_trial_until(acct_id, future)
resp = client.get("/", follow_redirects=True)
assert resp.status_code == 200
html = resp.text
assert "Plan: Pro" in html, f"'Plan: Pro' lipseste din burger in trial. HTML: {html[:800]}"
assert "trial" in html.lower(), f"'trial' lipseste din linia de plan din burger. HTML: {html[:800]}"

View File

@@ -137,60 +137,57 @@ def test_paleta_accent_azur_definita(client):
)
# ── test_font_ibm_plex_aplicat ────────────────────────────────────────────────
# ── test_font_system_stack_aplicat ───────────────────────────────────────────
def test_font_ibm_plex_aplicat(client):
"""IBM Plex Sans si IBM Plex Mono declarate in font-family si @font-face cu font-display:swap.
def test_font_system_stack_aplicat(client):
"""US-001 (PRD 5.16): IBM Plex eliminat; body foloseste stiva de fonturi sistem.
Verifica:
- body font-family contine 'IBM Plex Sans' (sau alias ibm-plex-sans)
- exista cel putin un @font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono'
- @font-face include font-display:swap
- @font-face pointeaza spre /static/fonts/
- body font-family foloseste var(--font-ui) (CSS custom property)
- --font-ui este definit in :root si contine un system font stack (system-ui / -apple-system)
- ZERO @font-face cu 'IBM Plex' in <style> (IBM Plex eliminat complet)
- ZERO referinte catre /static/fonts/ in HTML (nu se mai servesc fisiere woff2)
"""
resp = client.get("/login")
assert resp.status_code == 200
style = _get_style_block(resp.text)
# 1. body font-family contine IBM Plex Sans
# 1. body font-family refera var(--font-ui) (nu IBM Plex inline)
body_m = re.search(r"body\s*\{([^}]+)\}", style, re.DOTALL)
assert body_m, "Regula 'body { ... }' negasita in <style>"
body_block = body_m.group(1)
assert "IBM Plex Sans" in body_block or "ibm-plex-sans" in body_block.lower(), (
f"'IBM Plex Sans' lipseste din font-family al body. body block: {body_block.strip()}"
assert "var(--font-ui)" in body_block, (
f"body font-family trebuie sa foloseasca var(--font-ui) (sistem font stack). "
f"body block: {body_block.strip()}"
)
# 2. Exista cel putin un @font-face cu IBM Plex
# 2. --font-ui definit in :root si contine un system font stack
root_m = re.search(r":root\s*\{([^}]+)\}", style, re.DOTALL)
assert root_m, "Blocul :root negasit in <style>"
root_block = root_m.group(1)
assert "--font-ui" in root_block, (
f"--font-ui lipseste din :root. Continut :root: {root_block.strip()}"
)
font_ui_m = re.search(r"--font-ui\s*:\s*([^;]+)", root_block)
assert font_ui_m, "--font-ui negasit in blocul :root"
font_ui_val = font_ui_m.group(1).lower()
assert "system-ui" in font_ui_val or "-apple-system" in font_ui_val, (
f"--font-ui trebuie sa contina system-ui sau -apple-system (stiva sistem). "
f"Valoare gasita: {font_ui_m.group(1).strip()}"
)
# 3. ZERO @font-face cu IBM Plex (eliminat in US-001)
font_face_blocks = re.findall(r"@font-face\s*\{([^}]+)\}", style, re.DOTALL)
assert font_face_blocks, "@font-face negasit in <style>"
ibm_face = [b for b in font_face_blocks if "IBM Plex" in b or "ibm-plex" in b.lower()]
assert ibm_face, (
"@font-face cu 'IBM Plex Sans' sau 'IBM Plex Mono' negasit. "
f"Blocuri @font-face gasite: {font_face_blocks}"
assert not ibm_face, (
f"@font-face cu IBM Plex trebuia eliminat (US-001 PRD 5.16). "
f"Blocat gasit: {ibm_face}"
)
# 3. font-display:swap prezent in cel putin un bloc IBM Plex @font-face
swap_present = any("swap" in b.lower() for b in ibm_face)
assert swap_present, (
"font-display:swap lipseste din @font-face IBM Plex. "
f"Blocuri @font-face IBM Plex: {ibm_face}"
)
# 4. @font-face pointeaza spre /static/fonts/
fonts_src = any("/static/fonts/" in b for b in ibm_face)
assert fonts_src, (
"@font-face IBM Plex nu pointeaza spre /static/fonts/. "
f"Blocuri: {ibm_face}"
)
# 5. IBM Plex Mono pentru monospace: exista un context monospace cu IBM Plex Mono
# (fie @font-face, fie o regula font-family cu monospace)
has_mono = any("IBM Plex Mono" in b or "ibm-plex-mono" in b.lower() for b in font_face_blocks)
if not has_mono:
# Acceptam si daca e in o regula CSS (nu neaparat @font-face)
has_mono = "IBM Plex Mono" in style
assert has_mono, (
"'IBM Plex Mono' lipseste din <style> (trebuie pentru coduri RAR/VIN/nr)."
# 4. ZERO referinte /static/fonts/ in HTML randat (nu mai servim woff2)
html = resp.text
assert "/static/fonts/" not in html, (
"Referinte catre /static/fonts/ gasite in HTML — trebuie eliminate (US-001 PRD 5.16)."
)