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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
359
tests/test_plans.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -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]}"
|
||||
)
|
||||
|
||||
195
tests/test_web_import_e2e.py
Normal file
195
tests/test_web_import_e2e.py
Normal 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}"
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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 "✓" 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 "✗" 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 "✗" 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 "✗" 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]}"
|
||||
|
||||
@@ -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)."
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user