feat(5.12): modal editare + cont obligatoriu la import; design.md + PRD 5.13 revizuit (/autoplan)

5.12 (livrat): editare in modal a randurilor de preview, cont obligatoriu inainte de
import, formular editare extras (_form_editare, _editare_preview_modal), plus suita de
teste aferenta (preview edit/compact, mapare op, form editare, signup, admin panel).

Design + planificare:
- docs/design.md: sistem de design (tokeni, breakpoints, scara control, componente, a11y).
- docs/prd/prd-5.12-* si prd-5.13-* (5.13 cu raport /autoplan: CEO+Design+Eng, audit trail).

Curatare: sterse PNG-urile de test/mockup temporare din radacina.

Nota: implementarea CSS 5.13 (responsive compact + sistem butoane) NU e inca facuta —
planul revizuit cere refactorul testelor fragile din test_web_responsive.py INAINTE de CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-27 18:52:20 +00:00
parent 283299ff20
commit b26dbb79e1
44 changed files with 4852 additions and 305 deletions

View File

@@ -11,12 +11,26 @@ variabila exportata explicit in shell. Testele care chiar verifica enforcement-u
(auth pornit, creds <test>) il seteaza punctual prin `monkeypatch`/`object.__setattr__`.
"""
import hashlib
import os
os.environ.setdefault("AUTOPASS_REQUIRE_API_KEY", "false")
os.environ.setdefault("AUTOPASS_WORKER_USE_TEST_CREDS", "false")
def make_test_cui(seed: str = "") -> str:
"""Factory centralizat (D#14, PRD 5.12 US-001): genereaza un CUI de test unic din seed.
Folosit de fixture-urile de test care creeaza conturi via /signup sau create_account
si au nevoie de un CUI unic per test (altfel unicitatea CUI-ului bloca al doilea signup
cu acelasi seed in acelasi DB de test).
Formatul 'ROTE' + 8 hex-uri e suficient de unic per DB de test (izolata per test).
"""
h = hashlib.md5(seed.encode()).hexdigest()[:8].upper()
return f"ROTE{h}"
def pytest_configure(config):
"""Markeri custom. `live` = teste care ating endpoint-ul real RAR (opt-in,
skip implicit; vezi tests/test_live_rar.py). Excludere: `-m 'not live'`."""

View File

@@ -112,4 +112,61 @@ def test_list_accounts_ordonat_fara_creds(conn):
assert ids == sorted(ids)
for r in rows:
assert "rar_creds_enc" not in r
assert set(r.keys()) == {"id", "name", "cui", "active", "status", "created_at"}
assert set(r.keys()) == {"id", "name", "cui", "email", "active", "status", "created_at"}
# ---------------------------------------------------------------------------
# US-001 (PRD 5.12): accounts.email + validari companie/email/CUI
# ---------------------------------------------------------------------------
def test_create_account_fara_email_ridica(conn):
"""create_account cu email="" ridica ValueError (email gol nu e acceptat)."""
from app.accounts import create_account
with pytest.raises(ValueError, match="email"):
create_account(conn, "Service X", cui="RO100", email="")
def test_create_account_fara_cui_ridica(conn):
"""create_account cu cui="" ridica ValueError (CUI gol nu e acceptat)."""
from app.accounts import create_account
with pytest.raises(ValueError, match="[Cc][Uu][Ii]|cod unic"):
create_account(conn, "Service X", cui="", email="test@test.com")
def test_email_normalizat_lowercase_trim(conn):
"""email e normalizat: trim + lower."""
from app.accounts import create_account
acct_id = create_account(conn, "Service X", cui="RO200", email=" Test@EXAMPLE.Com ")
row = conn.execute("SELECT email FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert row["email"] == "test@example.com"
def test_migrare_adauga_coloana_email_idempotent(conn):
"""_migrate e idempotent: ruleaza de doua ori fara eroare si coloana email exista."""
from app.db import _migrate
_migrate(conn) # a doua rulare (prima e in init_db)
cols = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "email" in cols
def test_account_is_complete_false_pe_legacy_incomplet(conn):
"""account_is_complete() returneaza False pe cont fara email sau fara CUI."""
from app.accounts import create_account, account_is_complete
# cont fara email si fara CUI
acct_id = create_account(conn, "Service Legacy")
row = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id,)).fetchone()
assert account_is_complete(row) is False
# cont fara email, cu CUI
acct_id2 = create_account(conn, "Service Cu CUI", cui="RO300")
row2 = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id2,)).fetchone()
assert account_is_complete(row2) is False
# cont complet (cu email si CUI si name)
acct_id3 = create_account(conn, "Service Complet", cui="RO301", email="x@y.com")
row3 = conn.execute("SELECT * FROM accounts WHERE id=?", (acct_id3,)).fetchone()
assert account_is_complete(row3) is True
# 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

View File

@@ -36,8 +36,10 @@ def _csrf(client, url="/admin"):
def _signup(client, name, email, password="parola_test_001"):
from tests.conftest import make_test_cui
tok = _csrf(client, "/signup")
resp = client.post("/signup", data={"name": name, "email": email, "parola": password,
resp = client.post("/signup", data={"name": name, "cui": make_test_cui(email),
"email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
assert resp.status_code == 200
from app.db import get_connection

View File

@@ -55,9 +55,11 @@ def _get_csrf(client: TestClient, url: str) -> str:
def _signup(client: TestClient, name: str, email: str, password: str = "parola_test_001") -> int:
"""Creeaza cont via POST /signup si intoarce account_id."""
from tests.conftest import make_test_cui
token = _get_csrf(client, "/signup")
resp = client.post("/signup", data={
"name": name,
"cui": make_test_cui(email),
"email": email,
"parola": password,
"csrf_token": token,
@@ -211,3 +213,51 @@ def test_activate_fara_csrf_403(client):
assert resp.status_code == 403, (
f"POST fara CSRF trebuia 403, got {resp.status_code}"
)
def test_activare_cont_incomplet_refuzata(client):
"""Admin nu poate activa un cont incomplet (fara email/CUI) — contul ramane pending.
Gate pe account_is_complete: un cont fara companie+email+CUI nu poate fi activat
de admin (buton dezactivat in UI + server refuza activarea).
"""
# Cream cont pending INCOMPLET direct prin create_account (fara email/CUI)
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
incomplete_id = create_account(conn, "Firma Incompleta SRL", active=False)
create_user(conn, incomplete_id, "incompleta@test.ro", "parola_test_001")
finally:
conn.close()
# Admin
admin_id = _signup(client, "Admin Gate SA", "admin_gate@test.ro")
_make_admin(admin_id)
_login(client, "admin_gate@test.ro")
# Obtine CSRF din /admin
resp = client.get("/admin")
assert resp.status_code == 200
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit in /admin"
csrf = m.group(1)
# Incearca sa activeze contul incomplet
resp2 = client.post("/admin/activate", data={
"account_id": str(incomplete_id),
"csrf_token": csrf,
})
# Fie 303 redirect, fie pagina cu eroare — important: contul NU e activat
assert resp2.status_code in (200, 303, 422), (
f"Raspuns neasteptat: {resp2.status_code}"
)
# Verifica in DB: contul ramane pending (neactivat)
assert not _get_account_active(incomplete_id), (
"Contul incomplet (fara email/CUI) a fost activat — gate pe account_is_complete nu functioneaza"
)

View File

@@ -143,7 +143,8 @@ def test_preview_arata_panoul_de_mapare(client):
assert r.status_code == 200
assert "Operatii de mapat la cod RAR" in r.text
assert "OP-REV" in r.text
assert "/_import/%d/mapare-operatie" % import_id in r.text
# US-004: panoul foloseste ruta plurala (un singur form pentru toate operatiile)
assert "/_import/%d/mapare-operatii" % import_id in r.text
# --------------------------------------------------------------------------- #

618
tests/test_import_review.py Normal file
View File

@@ -0,0 +1,618 @@
"""Teste US-007 (PRD 5.12): gate review in modal — import_rows.reviewed + confirmare.
TDD RED: testele sunt scrise inainte de implementare.
Scenarii:
1. Migrare: import_rows.reviewed INTEGER DEFAULT 0, idempotent.
2. Rand needs_review exclus din "gata de trimis" pana la confirmare explicita.
3. POST confirma-review seteaza reviewed=1 → randul devine ok la recalcul.
4. `reviewed` NU intra in payload/idempotency (marcaj separat).
5. Editarea unei valori pe un rand confirmat reseteaza reviewed=0 (D#9).
"""
from __future__ import annotations
import io
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client fara autentificare web obligatorie (cont 1 implicit)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "ir.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _seed_op1(account_id: int = 1) -> None:
"""Semeaza nomenclator + mapare OP-1 → R-FRANE."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
"VALUES ('R-FRANE','Reparatie frane')"
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, 'OP-1', 'R-FRANE', 1)",
(account_id,),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv as _csv
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")
# Rand care declanseaza needs_review: data in format ambiguu (DD.MM.YYYY cu zi<=12)
# si format_data=None -> col_fmt="ambiguous" -> is_ambiguous_date=True
_ROWS_NEEDS_REVIEW = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr": "B001TST",
"Data": "05.06.2026", # Format ambiguu: zi=5 <= 12, luna=6 <= 12
"KM": "123456",
"Operatie": "OP-1",
},
]
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _get_csrf(client: TestClient) -> str:
r = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
def _upload_and_preview_needs_review(client: TestClient) -> int:
"""Upload CSV cu data ambigua + salveaza mapare fara format_data → preview.
format_data=None → col_fmt='ambiguous' → data '05.06.2026' → is_ambiguous=True
→ flag needs_review in _resolve_row_for_preview.
Intoarce import_id.
"""
rows = _ROWS_NEEDS_REVIEW
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _get_csrf(client)
# IMPORTANT: format_data="" (gol/None) → date_col_format={} → col_fmt="ambiguous"
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "", # fara format -> ambiguous
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
return iid
def _get_reviewed(import_id: int, row_index: int) -> int:
"""Citeste valoarea reviewed din DB pentru un rand."""
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute(
"SELECT reviewed FROM import_rows WHERE batch_id=? AND row_index=?",
(import_id, row_index),
).fetchone()
return int(row["reviewed"]) if row else -1
finally:
conn.close()
def _get_idempotency_key(import_id: int, row_index: int) -> str | None:
"""Citeste cheia de idempotenta a unui rand prin endpoint-ul de preview."""
from app.db import get_connection
from app.crypto import decrypt_creds
from app.api.v1.import_router import (
_resolve_row_for_preview, _signature, _build_idempotency_key,
)
import json
from app.mapping import account_or_default, load_mapping_meta, load_nomenclator_codes, load_text_rules
from app.import_parse import parse_date_value
conn = get_connection()
try:
acct = account_or_default(1)
row_db = conn.execute(
"SELECT raw_json, override_json, reviewed FROM import_rows "
"WHERE batch_id=? AND row_index=?",
(import_id, row_index),
).fetchone()
if not row_db:
return None
raw = decrypt_creds(row_db["raw_json"]) or {}
ov = decrypt_creds(row_db["override_json"]) if row_db["override_json"] else None
col_names = list(raw.keys())
sig = _signature(col_names)
mapping_row = conn.execute(
"SELECT json_mapare, format_data FROM column_mappings "
"WHERE account_id=? AND signature_coloane=?",
(acct, sig),
).fetchone()
if not mapping_row:
return None
json_mapare = json.loads(mapping_row["json_mapare"])
format_data = mapping_row["format_data"]
date_col_format: dict[str, str] = {}
if format_data:
for col_f, camp_c in json_mapare.items():
if camp_c == "data_prestatie":
date_col_format[col_f] = format_data
mapping_meta = load_mapping_meta(conn, acct)
mapping = {op: meta["cod_prestatie"] for op, meta in mapping_meta.items()}
valid_codes = load_nomenclator_codes(conn) or None
text_rules = load_text_rules(conn, acct)
info = _resolve_row_for_preview(
raw_row=raw,
json_mapare=json_mapare,
date_col_format=date_col_format,
coercion_flags=[],
mapping=mapping,
mapping_meta=mapping_meta,
formula_columns=[],
override=ov or None,
valid_codes=valid_codes,
text_rules=text_rules,
reviewed=bool(row_db["reviewed"]),
)
try:
return _build_idempotency_key(1, info["resolved"])
except Exception:
return None
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_migrare_adauga_coloana_reviewed_idempotent():
"""_migrate adauga coloana import_rows.reviewed INTEGER DEFAULT 0.
Idempotent: a doua invocare nu ridica eroare.
"""
tmp = tempfile.mkdtemp()
import os
os.environ["AUTOPASS_DB_PATH"] = os.path.join(tmp, "m.db")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
try:
from app.db import get_connection, _migrate
# Initializare DB (inclusiv _migrate)
from app.db import init_db
init_db()
conn = get_connection()
try:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()}
assert "reviewed" in cols, \
"import_rows trebuie sa aiba coloana 'reviewed' dupa init_db()"
# Verifica DEFAULT 0
row_info = {r["name"]: r for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()}
reviewed_col = row_info.get("reviewed")
assert reviewed_col is not None
# dflt_value poate fi "0" sau 0 in functie de SQLite versiune
assert str(reviewed_col["dflt_value"]) == "0", \
f"import_rows.reviewed trebuie sa aiba DEFAULT 0, got: {reviewed_col['dflt_value']}"
# Idempotenta: a doua invocare a _migrate nu ridica eroare
_migrate(conn) # no exception
finally:
conn.close()
finally:
del os.environ["AUTOPASS_DB_PATH"]
get_settings.cache_clear()
reset_cache()
def test_needs_review_exclus_din_gata_pana_la_confirmare(client):
"""Un rand needs_review nu intra in 'gata de trimis' (n_confirmat = 0) pana la confirmare.
US-007 Q1: randul cu data ambigua apare cu pill 'Verifica valori' si este
EXCLUS din n_confirmat. Bannerul de discoverability (T1) trebuie sa fie prezent.
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Verifica preview via GET /_import/{id}/preview
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
# Randul are starea needs_review
assert "needs_review" in html, \
"Randul cu data ambigua trebuie sa apara cu starea needs_review in preview"
# Pill-ul cu eticheta "Verifica valori"
assert "Verifica valori" in html or "verifica valori" in html.lower(), \
"Pill-ul 'Verifica valori' trebuie sa apara pentru randul needs_review"
# n_confirmat = 0 (randul NU e in ok)
# Cautam valoarea campului n_confirmat (value="0")
n_match = re.search(r'id="n-confirmat"[^>]*value="(\d+)"', html) or \
re.search(r'name="n_confirmat"[^>]*value="(\d+)"', html)
if n_match:
n_val = int(n_match.group(1))
assert n_val == 0, \
f"n_confirmat trebuie sa fie 0 cand randul e needs_review (negasit), got {n_val}"
# Bannerul de discoverability (T1) — prezent cand summary.needs_review > 0
# Trebuie sa contina un mesaj despre faptul ca randurile nu pleaca pana la confirmare
# (ex. 'nu pleaca la RAR' sau 'confirmi in modal' sau 'Verifica valori')
banner_present = (
"nu pleaca" in html.lower() or
"confirmi in modal" in html.lower() or
"preview-needs-review-banner" in html
)
assert banner_present, \
"Bannerul de discoverability (T1) trebuie sa fie prezent cand exista randuri needs_review"
def test_confirmare_in_modal_seteaza_reviewed_si_devine_ok(client):
"""POST /_import/{id}/rand/0/confirma-review seteaza reviewed=1.
US-007 T2: operatorul apasa 'Confirma valorile' in modal →
reviewed=1 in DB → randul devine ok la recalcul preview.
Verifica:
- Raspuns 200
- reviewed=1 in DB
- Raspuns contine OOB cu pill 'Gata de trimis' (starea ok)
- Header HX-Trigger-After-Settle: inchideModal
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Verifica ca randul e needs_review inainte de confirmare
assert _get_reviewed(iid, 0) == 0, "reviewed trebuie sa fie 0 inainte de confirmare"
# POST confirma-review
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
# reviewed=1 in DB
assert _get_reviewed(iid, 0) == 1, \
"reviewed trebuie sa fie 1 in DB dupa confirmare"
# Raspuns contine OOB cu randul actualizat
html = r.text
assert 'id="preview-row-0"' in html or "preview-row-0" in html, \
"Raspunsul trebuie sa contina randul actualizat (OOB)"
# Starea a devenit ok
assert "Gata de trimis" in html or "s-ok" in html, \
"Dupa confirmare, randul trebuie sa fie ok (pill 'Gata de trimis')"
# Modal se inchide
trigger = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" in trigger, \
f"confirma-review trebuie sa emita inchideModal, got: '{trigger}'"
def test_reviewed_nu_intra_in_payload_sau_idempotency(client):
"""reviewed NU intra in payload, override_json sau cheia de idempotenta.
US-007 marcaj separat (D#8): reviewed e DOAR un flag de confirmare umana,
nu un camp de continut. Cheia de idempotenta trebuie sa fie identica inainte
si dupa confirmare.
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Cheia inainte de confirmare
key_before = _get_idempotency_key(iid, 0)
assert key_before is not None, "Cheia de idempotenta trebuie sa existe inainte de confirmare"
# Confirma
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
# Cheia dupa confirmare
key_after = _get_idempotency_key(iid, 0)
assert key_after is not None, "Cheia de idempotenta trebuie sa existe dupa confirmare"
assert key_before == key_after, \
f"Cheia de idempotenta NU trebuie sa se schimbe la confirmare! " \
f"Inainte: {key_before}, dupa: {key_after}. " \
"'reviewed' NU trebuie sa intre in payload sau cheia de idempotenta."
# Verifica ca 'reviewed' nu e in override_json
from app.db import get_connection
from app.crypto import decrypt_creds
conn = get_connection()
try:
row = conn.execute(
"SELECT override_json FROM import_rows WHERE batch_id=? AND row_index=?",
(iid, 0),
).fetchone()
if row and row["override_json"]:
ov = decrypt_creds(row["override_json"]) or {}
assert "reviewed" not in ov, \
"'reviewed' NU trebuie sa fie in override_json (camp separat, nu camp de continut)"
finally:
conn.close()
def test_editare_valoare_pe_needs_review_reseteaza_reviewed(client):
"""Editarea unei valori pe un rand confirmat reseteaza reviewed=0 (D#9).
Cand operatorul SCHIMBA o valoare (via POST editeaza) pe un rand deja confirmat
(reviewed=1), apply_row_override trebuie sa reseteze reviewed=0.
→ Randul se intoarce in starea needs_review si cere re-confirmare.
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Confirma randul
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
assert _get_reviewed(iid, 0) == 1, "reviewed trebuie sa fie 1 dupa confirmare"
# Editeaza o valoare (odometru_final)
csrf2 = _get_csrf(client)
r2 = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"odometru_final": "200000",
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
# reviewed trebuie sa fie 0 (resetat de apply_row_override)
assert _get_reviewed(iid, 0) == 0, \
"reviewed trebuie sa fie resetat la 0 dupa editarea unei valori (D#9 — re-cere confirmare)"
def test_confirma_review_guard_committed_409(client):
"""POST confirma-review pe batch deja comis → 409 (guard committed)."""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
# Marcheaza batch ca committed
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE import_batches SET status='committed' WHERE id=?", (iid,))
conn.commit()
finally:
conn.close()
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 409, \
f"confirma-review pe batch committed trebuie sa returneze 409, got {r.status_code}"
def test_confirma_review_scoped_404_alt_cont():
"""POST confirma-review pe un rand al altui cont → 404 (scoping JOIN)."""
tmp = tempfile.mkdtemp()
env_patch = {
"AUTOPASS_DB_PATH": os.path.join(tmp, "scope.db"),
"AUTOPASS_WEB_AUTH_REQUIRED": "true",
}
for k, v in env_patch.items():
os.environ[k] = v
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
try:
with TestClient(app, follow_redirects=False) as c:
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user
conn = get_connection()
try:
acct1 = create_account(conn, "Firma A", active=True)
create_user(conn, acct1, "userA@test.com", "parola123secure")
acct2 = create_account(conn, "Firma B", active=True)
create_user(conn, acct2, "userB@test.com", "parola123secure")
finally:
conn.close()
# Semeaza operatii pentru acct1
from app.db import get_connection as gcn
conn2 = gcn()
try:
conn2.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
"VALUES ('R-FRANE','Reparatie frane')"
)
conn2.execute(
"INSERT OR IGNORE INTO operations_mapping "
"(account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, 'OP-1', 'R-FRANE', 1)", (acct1,)
)
conn2.commit()
finally:
conn2.close()
# Login ca userA, creeaza batch
def _login(client, email, pwd="parola123secure"):
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
client.post("/login", data={
"email": email, "parola": pwd, "csrf_token": m.group(1),
})
_login(c, "userA@test.com")
rows = _ROWS_NEEDS_REVIEW
csv_data = _csv_bytes(rows)
def _csrf():
r = c.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
csrf = _csrf()
r = c.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _csrf()
c.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames, "canon": canons,
"format_data": "", "csrf_token": csrf2,
})
# Login ca userB si incearca confirma-review pe batch-ul lui A
_login(c, "userB@test.com")
csrf3 = _csrf()
r2 = c.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf3})
assert r2.status_code == 404, \
f"confirma-review cross-account trebuie sa returneze 404, got {r2.status_code}"
finally:
for k in env_patch:
if k in os.environ:
del os.environ[k]
ratelimit._hits.clear()
get_settings.cache_clear()
reset_cache()
# --------------------------------------------------------------------------- #
# Teste markup Bug 1: confirma-review form swap (B1) #
# --------------------------------------------------------------------------- #
def test_confirma_review_form_nu_foloseste_hx_swap_none():
"""Bug B1 (markup): formularul confirma-review NU trebuie sa foloseasca
hx-swap='none' — cu none, htmx aplica doar OOB-urile dar NU executa scriptul
din continutul principal → updateN() nu ruleaza → n_confirmat stale → 422.
Forma corecta: hx-target='#detaliu-modal-body' + hx-swap='innerHTML'.
"""
import pathlib
template = (
pathlib.Path(__file__).parent.parent
/ "app/web/templates/_editare_preview_modal.html"
)
html = template.read_text(encoding="utf-8")
# Gasim sectiunea formularului confirma-review (dupa marcajul T2)
idx = html.find("confirma-review")
assert idx >= 0, "Formularul confirma-review nu a fost gasit in template"
form_section = html[idx:]
# NU trebuie sa existe hx-swap="none" pe formularul confirma-review
assert 'hx-swap="none"' not in form_section, (
"Formularul confirma-review NU trebuie sa foloseasca hx-swap='none'. "
"Cu none, scriptul updateN() nu ruleaza → n_confirmat stale → gate 422."
)
# TREBUIE sa tinteasca #detaliu-modal-body cu innerHTML
assert 'hx-target="#detaliu-modal-body"' in form_section, (
"Formularul confirma-review trebuie sa aiba hx-target='#detaliu-modal-body' "
"ca scriptul updateN sa fie executat (identic cu formularul editeaza)."
)
assert 'hx-swap="innerHTML"' in form_section, (
"Formularul confirma-review trebuie sa aiba hx-swap='innerHTML' "
"ca scriptul updateN sa fie executat."
)
def test_confirma_review_raspuns_contine_script_updateN(client):
"""Bug B1 (functional): raspunsul POST confirma-review contine scriptul
updateN in payload-ul principal (nu doar OOB), astfel ca htmx il va executa
cand face swap in #detaliu-modal-body.
Verifica:
- Raspuns 200
- Raspunsul contine 'window.updateN' (scriptul de recalcul contor)
- Raspunsul contine 'updateN' inainte de ultimul OOB-element (@script tag nu e OOB)
"""
_seed_op1()
iid = _upload_and_preview_needs_review(client)
csrf = _get_csrf(client)
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text
html = r.text
# Scriptul trebuie sa fie in raspuns
assert "window.updateN" in html or "updateN" in html, (
"Raspunsul confirma-review trebuie sa contina scriptul updateN "
"pentru ca htmx sa-l execute la swap in #detaliu-modal-body."
)
# Scriptul NU trebuie sa aiba hx-swap-oob (altfel nu ar fi executat nici asa)
script_idx = html.rfind("<script>")
assert script_idx >= 0, "Tag-ul <script> nu a fost gasit in raspuns"
script_content = html[script_idx:]
assert "hx-swap-oob" not in script_content, (
"Scriptul updateN NU trebuie sa aiba hx-swap-oob — trebuie sa fie in "
"continutul principal pentru executie."
)

203
tests/test_signup.py Normal file
View File

@@ -0,0 +1,203 @@
"""Teste US-001 (PRD 5.12): companie/email/CUI obligatorii la signup.
TDD strict: testele se scriu INAINTE de implementare (RED), dupa implementare trec (GREEN).
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_signup_us001.db"))
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _csrf(html: str) -> str:
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', html)
if not m:
m = re.search(r'value="([^"]+)"\s+name="csrf_token"', html)
assert m, "csrf_token negasit in HTML"
return m.group(1)
def test_signup_html_cui_obligatoriu_ui(client):
"""GET /signup: campul CUI NU contine '(optional)' si are atribut required (US-001 UI)."""
resp = client.get("/signup")
assert resp.status_code == 200
# (a) NU trebuie sa apara textul "(optional)" langa CUI
assert "(optional)" not in resp.text, "Campul CUI afiseaza '(optional)' — trebuie sa fie obligatoriu"
# (b) input[name=cui] trebuie sa aiba atribut required
assert 'name="cui"' in resp.text
# cautam required pe aceeasi linie cu name="cui" sau intr-un bloc care contine name="cui" required
import re
# fie pe aceeasi linie: <input ... name="cui" ... required ...>
# fie in orice forma cu required si name="cui" in acelasi tag
cui_input_match = re.search(r'<input[^>]*name="cui"[^>]*>', resp.text)
assert cui_input_match, "input name='cui' negasit in HTML"
assert "required" in cui_input_match.group(0), (
f"input[name='cui'] NU are atribut required: {cui_input_match.group(0)}"
)
def test_signup_fara_cui_422(client):
"""POST /signup fara CUI -> 422, formular re-randat cu eroare, fara cont creat."""
resp = client.get("/signup")
token = _csrf(resp.text)
resp = client.post("/signup", data={
"name": "Service Fara CUI",
"cui": "",
"email": "fara_cui@test.com",
"parola": "parolasecreta123",
"csrf_token": token,
})
# trebuie sa returneze 422 (sau sa randeze formularul cu eroare)
assert resp.status_code == 422
# cheia API nu trebuie sa apara
assert "rfak_" not in resp.text
# campul name trebuie sa fie pastrat (form re-render cu valorile existente)
assert "Service Fara CUI" in resp.text
# verifica ca nu s-a creat niciun cont
from app.db import get_connection
conn = get_connection()
try:
n = conn.execute(
"SELECT COUNT(*) AS n FROM accounts WHERE name='Service Fara CUI'"
).fetchone()["n"]
assert n == 0, "Cont creat desi CUI lipsea"
finally:
conn.close()
def test_signup_scrie_email_pe_account(client):
"""POST /signup valid -> accounts.email = emailul utilizatorului."""
resp = client.get("/signup")
token = _csrf(resp.text)
resp = client.post("/signup", data={
"name": "Service Cu Email",
"cui": "RO9999001",
"email": "cu_email@test.com",
"parola": "parolasecreta123",
"csrf_token": token,
})
assert resp.status_code == 200
assert "rfak_" in resp.text
from app.db import get_connection
conn = get_connection()
try:
acct = conn.execute(
"SELECT * FROM accounts WHERE name='Service Cu Email'"
).fetchone()
assert acct is not None
# emailul trebuie scris pe cont (normalizat: lower + trim)
assert acct["email"] == "cu_email@test.com"
finally:
conn.close()
def test_signup_email_duplicat_mesaj_email(client):
"""POST /signup cu email existent dar CUI nou -> mesaj despre EMAIL, NU despre CUI/firma.
Bug: 'email deja folosit' contine 'deja folosit' -> era prins de conditia CUI duplicat
si afisa gresit 'Aceasta firma (CUI X) e deja inregistrata' (CUI nou, NU cauza reala).
Fix: verifica intai email-ul, apoi CUI-ul.
"""
from tests.conftest import make_test_cui
# primul signup cu email E + CUI C1
resp = client.get("/signup")
token = _csrf(resp.text)
resp1 = client.post("/signup", data={
"name": "Firma Prima SRL",
"cui": make_test_cui("email-dup-c1"),
"email": "emaildup@test.com",
"parola": "parolasecreta123",
"csrf_token": token,
})
assert resp1.status_code == 200
assert "rfak_" in resp1.text, "Primul signup trebuia sa reuseasca"
# al doilea signup cu ACELASI email dar CUI NOU
resp = client.get("/signup")
token2 = _csrf(resp.text)
cui_nou = make_test_cui("email-dup-c2")
resp2 = client.post("/signup", data={
"name": "Firma A Doua SRL",
"cui": cui_nou,
"email": "emaildup@test.com",
"parola": "parolasecreta456",
"csrf_token": token2,
})
assert resp2.status_code in (200, 422)
assert "rfak_" not in resp2.text, "Nu trebuia creata cheie API la email duplicat"
body_lower = resp2.text.lower()
# mesajul trebuie sa se refere la EMAIL
assert "email" in body_lower, (
f"Mesajul de eroare nu mentioneaza 'email': {resp2.text[:500]}"
)
# mesajul NU trebuie sa afiseze pattern-ul gresit cu firma si CUI-ul nou
# (CUI-ul apare legitim si in campul pre-completat al formularului, dar nu in mesajul de eroare)
wrong_pattern = f"(cui {cui_nou.lower()}) e deja inregistrata"
assert wrong_pattern not in body_lower, (
f"Mesajul arata gresit pattern-ul CUI-duplicat desi problema e emailul: {resp2.text[:500]}"
)
# nu trebuie sa apara mesajul specific CUI-duplicat
assert "e deja inregistrata" not in body_lower, (
f"Mesajul arata 'e deja inregistrata' (mesaj CUI) la eroare de email: {resp2.text[:500]}"
)
def test_signup_cui_existent_mesaj_prietenos(client):
"""POST /signup cu CUI existent -> mesaj prietenos, NU mesaj tehnic cu 'activate --account'."""
resp = client.get("/signup")
token = _csrf(resp.text)
# primul signup
client.post("/signup", data={
"name": "Firma Existenta SRL",
"cui": "RO8888001",
"email": "firma1@test.com",
"parola": "parolasecreta123",
"csrf_token": token,
})
# al doilea signup cu acelasi CUI
resp = client.get("/signup")
token2 = _csrf(resp.text)
resp2 = client.post("/signup", data={
"name": "Alt Utilizator SRL",
"cui": "RO8888001",
"email": "firma2@test.com",
"parola": "parolasecreta456",
"csrf_token": token2,
})
assert resp2.status_code in (200, 422)
# NU trebuie sa apara mesajul tehnic cu referinta la CLI
assert "activate --account" not in resp2.text
# trebuie sa apara un mesaj prietenos cu CUI-ul
assert "RO8888001" in resp2.text
# trebuie sa contina cuvant cheie de tip "firma" sau "inregistrata"
body_lower = resp2.text.lower()
assert any(kw in body_lower for kw in ["firma", "inregistrat", "cont", "acces"])

View File

@@ -45,9 +45,11 @@ def _csrf(c: TestClient) -> str:
def _do_signup(c: TestClient, name: str, email: str, parola: str = "parolasecreta") -> object:
from tests.conftest import make_test_cui
token = _csrf(c)
return c.post("/signup", data={
"name": name,
"cui": make_test_cui(email),
"email": email,
"parola": parola,
"csrf_token": token,

View File

@@ -24,7 +24,7 @@ def _run(argv):
def test_create_afiseaza_id(env, capsys):
rc = _run(["create", "--name", "Service X"])
rc = _run(["create", "--name", "Service X", "--cui", "RO001", "--email", "x@test.com"])
out = capsys.readouterr().out
assert rc == 0
assert "id=2" in out
@@ -32,14 +32,14 @@ def test_create_afiseaza_id(env, capsys):
def test_create_inactive_in_asteptare(env, capsys):
rc = _run(["create", "--name", "Service X", "--inactive"])
rc = _run(["create", "--name", "Service X", "--cui", "RO002", "--email", "x2@test.com", "--inactive"])
out = capsys.readouterr().out
assert rc == 0
assert "activ=nu" in out
def test_create_with_key_emite_cheie(env, capsys):
rc = _run(["create", "--name", "Service X", "--with-key"])
rc = _run(["create", "--name", "Service X", "--cui", "RO003", "--email", "x3@test.com", "--with-key"])
out = capsys.readouterr().out
assert rc == 0
assert "rfak_" in out
@@ -57,8 +57,8 @@ def test_create_with_key_emite_cheie(env, capsys):
def test_create_cui_duplicat_exit_2(env, capsys):
assert _run(["create", "--name", "Service A", "--cui", "RO123"]) == 0
rc = _run(["create", "--name", "Service B", "--cui", "RO123"])
assert _run(["create", "--name", "Service A", "--cui", "RO123", "--email", "a@test.com"]) == 0
rc = _run(["create", "--name", "Service B", "--cui", "RO123", "--email", "b@test.com"])
err = capsys.readouterr().err
assert rc == 2
assert "RO123" in err
@@ -66,10 +66,10 @@ def test_create_cui_duplicat_exit_2(env, capsys):
def test_with_key_atomic_pe_cui_duplicat(env, capsys):
# cont initial care ocupa CUI
assert _run(["create", "--name", "Service A", "--cui", "RO123"]) == 0
assert _run(["create", "--name", "Service A", "--cui", "RO123", "--email", "a@test.com"]) == 0
capsys.readouterr()
# --with-key pe CUI duplicat: rollback -> niciun cont B, nicio cheie orfana
rc = _run(["create", "--name", "Service B", "--cui", "RO123", "--with-key"])
rc = _run(["create", "--name", "Service B", "--cui", "RO123", "--email", "b@test.com", "--with-key"])
assert rc == 2
from app.db import get_connection
conn = get_connection()
@@ -82,7 +82,7 @@ def test_with_key_atomic_pe_cui_duplicat(env, capsys):
def test_activate_comuta_starea(env, capsys):
_run(["create", "--name", "Service X", "--inactive"])
_run(["create", "--name", "Service X", "--cui", "RO004", "--email", "x4@test.com", "--inactive"])
capsys.readouterr()
assert _run(["deactivate", "--account", "2"]) == 0
assert _run(["activate", "--account", "2"]) == 0
@@ -104,7 +104,7 @@ def test_activate_inexistent_exit_2(env, capsys):
def test_list_afiseaza_activ(env, capsys):
_run(["create", "--name", "Service X"])
_run(["create", "--name", "Service X", "--cui", "RO005", "--email", "x5@test.com"])
capsys.readouterr()
rc = _run(["list"])
out = capsys.readouterr().out
@@ -114,8 +114,8 @@ def test_list_afiseaza_activ(env, capsys):
def test_list_pending_filtreaza(env, capsys):
_run(["create", "--name", "Activ SRL"])
_run(["create", "--name", "Asteptare SRL", "--inactive"])
_run(["create", "--name", "Activ SRL", "--cui", "RO006", "--email", "activ@test.com"])
_run(["create", "--name", "Asteptare SRL", "--cui", "RO007", "--email", "ast@test.com", "--inactive"])
capsys.readouterr()
rc = _run(["list", "--pending"])
out = capsys.readouterr().out

View File

@@ -37,9 +37,10 @@ def _csrf(client, url):
def _signup(client, name, email, password="parola_test_001"):
from tests.conftest import make_test_cui
tok = _csrf(client, "/signup")
client.post("/signup", data={"name": name, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
client.post("/signup", data={"name": name, "cui": make_test_cui(email), "email": email,
"parola": password, "csrf_token": tok}, follow_redirects=True)
from app.db import get_connection
conn = get_connection()
try:

View File

@@ -230,3 +230,126 @@ def test_fragment_cont_nelogat_redirect(monkeypatch):
assert resp.status_code == 303
assert "/login" in resp.headers.get("location", "")
get_settings.cache_clear()
# ============================================================
# US-002: sectiunea 'Date firma' + banner cont incomplet
# ============================================================
def _create_complete_account(
name: str = "Firma Test SRL",
login_email: str = "firma_test@test.com",
account_email: str = "contact@firma.com",
cui: str = "RO12345678",
password: str = "parolasecreta10",
):
"""Creeaza cont COMPLET (name+email+CUI) + user. Intoarce (acct_id, user_id)."""
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, cui=cui, email=account_email, active=True)
user_id = create_user(conn, acct_id, login_email, password)
return acct_id, user_id
finally:
conn.close()
def test_cont_afiseaza_companie_email_cui(client):
"""Fragment /_fragments/cont contine sectiunea 'Date firma' cu companie, email, CUI prefilled."""
_create_complete_account(
name="Test Firma SRL",
login_email="tfirma@test.com",
account_email="contact_tf@test.com",
cui="RO11111111",
)
_login(client, "tfirma@test.com", "parolasecreta10")
resp = client.get("/_fragments/cont")
assert resp.status_code == 200
assert "Date firma" in resp.text or "date-firma" in resp.text, \
f"Sectiunea 'Date firma' lipseste: {resp.text[:500]}"
assert "Test Firma SRL" in resp.text, f"Compania nu e prefilled: {resp.text[:500]}"
assert "contact_tf@test.com" in resp.text, f"Email-ul nu e prefilled: {resp.text[:500]}"
assert "RO11111111" in resp.text, f"CUI-ul nu e prefilled: {resp.text[:500]}"
def test_post_date_firma_actualizeaza(client):
"""POST /cont/date-firma actualizeaza accounts.name, accounts.email, accounts.cui in DB."""
acct_id, user_id, _ = _create_account_user("update_df@test.com")
_login(client, "update_df@test.com", "parolasecreta10")
csrf = _get_csrf_from_fragment(client)
resp = client.post("/cont/date-firma", data={
"csrf_token": csrf,
"companie": "Firma Actualizata SRL",
"email": "contact@firma-act.com",
"cui": "RO99887766",
})
assert resp.status_code == 200, f"POST /cont/date-firma a returnat {resp.status_code}"
# Verifica in DB ca datele au fost actualizate
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute(
"SELECT name, email, cui FROM accounts WHERE id=?", (acct_id,)
).fetchone()
assert row["name"] == "Firma Actualizata SRL", f"name neschimbat: {row['name']}"
assert row["email"] == "contact@firma-act.com", f"email neschimbat: {row['email']}"
assert row["cui"] == "RO99887766", f"cui neschimbat: {row['cui']}"
finally:
conn.close()
def test_post_date_firma_cui_duplicat_eroare(client):
"""POST /cont/date-firma cu CUI deja folosit de alt cont -> eroare in raspuns."""
# Cont A cu CUI existent
_create_complete_account(
name="Firma A SRL",
login_email="firma_a_dup@test.com",
account_email="a_dup@test.com",
cui="ROAAA11111",
)
# Cont B fara CUI
acct_b, user_b, _ = _create_account_user("firma_b_dup@test.com")
_login(client, "firma_b_dup@test.com", "parolasecreta10")
csrf = _get_csrf_from_fragment(client)
resp = client.post("/cont/date-firma", data={
"csrf_token": csrf,
"companie": "Firma B SRL",
"email": "firma_b_dup@test.com",
"cui": "ROAAA11111", # CUI-ul lui A — duplicat
})
assert resp.status_code == 200
text = resp.text.lower()
assert "deja" in text or "duplicat" in text or "folosit" in text or "eroare" in text, \
f"Mesaj eroare CUI duplicat lipsa: {resp.text[:500]}"
# Contul B nu trebuie sa aiba CUI-ul lui A in DB
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT cui FROM accounts WHERE id=?", (acct_b,)).fetchone()
assert row["cui"] != "ROAAA11111", "CUI-ul duplicat a fost totusi salvat in DB"
finally:
conn.close()
def test_banner_cont_incomplet_pe_legacy(client):
"""Acasa afiseaza banner 'Completeaza datele firmei' cand contul e incomplet (fara email/CUI)."""
# Cont fara email/CUI (legacy: creat fara aceste campuri)
_create_account_user("legacy_test@test.com")
_login(client, "legacy_test@test.com", "parolasecreta10")
resp = client.get("/")
assert resp.status_code == 200
text = resp.text.lower()
# Banner trebuie sa apara cand contul e incomplet
assert "completeaza" in text or "date firm" in text or "incomplet" in text, \
f"Banner 'Completeaza datele firmei' lipsa pe Acasa: {resp.text[:2000]}"

View File

@@ -0,0 +1,174 @@
"""Teste US-005 (PRD 5.12): formular de editare partajat DRY + input date.
_form_editare.html — partial NOU cu campurile vehicul/data/odo.
_macros.html — macro `camp` extins cu tip='date'.
_trimitere_detaliu.html — consuma partial-ul in ramura editabil.
TDD: scriem testele RED inainte de implementare.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
from pathlib import Path
import pytest
from starlette.testclient import TestClient
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "web" / "templates"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_account_user(email: str, name: str = "Service", password: str = "parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, name, active=True)
create_user(conn, acct_id, email, password)
return acct_id
finally:
conn.close()
def _login(client, email: str, password: str = "parolasecreta10") -> None:
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303
def _insert(acct: int, *, status: str, payload: dict) -> int:
from app.db import get_connection
conn = get_connection()
try:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) VALUES (?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", acct, status, json.dumps(payload)),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _payload_needs_data(vin: str = "WVWZZZ1JZXW0FE001") -> dict:
return {
"vin": vin,
"nr_inmatriculare": "B200FE",
"data_prestatie": "2026-06-15",
"odometru_final": "", # gol -> needs_data
"prestatii": [{"cod_prestatie": "R-FRANE"}],
}
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "form_editare.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
# ---------------------------------------------------------------------------
# Test 1: fragmentul de trimitere randeaza <input type="date"> pentru data_prestatie
# ---------------------------------------------------------------------------
def test_form_editare_are_input_date_pe_data_prestatie(client):
"""Fragmentul de detaliu pentru un rand needs_data trebuie sa randereze
<input type="date"> pentru campul data_prestatie (calendar nativ, D#10/R3).
Inainte de US-005, campul e type="text" -> test RED.
"""
acct = _create_account_user("fe1@test.com")
sid = _insert(acct, status="needs_data", payload=_payload_needs_data())
_login(client, "fe1@test.com")
resp = client.get(f"/_fragments/trimitere/{sid}")
assert resp.status_code == 200
html = resp.text
# Campul data_prestatie trebuie sa fie type="date" (nu type="text").
assert 'name="data_prestatie"' in html, "campul data_prestatie lipseste din fragment"
# Cautam input cu name=data_prestatie si type=date.
assert re.search(r'<input[^>]+name="data_prestatie"[^>]+type="date"', html) or \
re.search(r'<input[^>]+type="date"[^>]+name="data_prestatie"', html), \
"data_prestatie trebuie sa fie <input type='date'>, nu type='text'"
# Asiguram ca NU este type="text" pentru data_prestatie.
# type="text" pe data_prestatie inseamna ca partial-ul nu e activ.
match_text = re.search(r'<input[^>]+name="data_prestatie"[^>]+type="text"', html) or \
re.search(r'<input[^>]+type="text"[^>]+name="data_prestatie"', html)
assert not match_text, \
"data_prestatie NU trebuie sa fie type='text' dupa US-005 (trebuie type='date')"
# ---------------------------------------------------------------------------
# Test 2: _trimitere_detaliu.html foloseste partial-ul _form_editare.html
# ---------------------------------------------------------------------------
def test_trimitere_detaliu_foloseste_form_partajat():
"""Sursa _trimitere_detaliu.html trebuie sa includa _form_editare.html.
Inainte de US-005, include lipseste -> test RED.
"""
sursa = (TEMPLATES_DIR / "_trimitere_detaliu.html").read_text(encoding="utf-8")
# Trebuie sa contina include sau import din _form_editare.html.
assert "_form_editare.html" in sursa, (
"_trimitere_detaliu.html nu referencieaza _form_editare.html. "
"US-005 cere ca partial-ul sa fie consumat in ramura editabil."
)
# ---------------------------------------------------------------------------
# Test 3: macro `camp` din _macros.html suporta tip='date'
# ---------------------------------------------------------------------------
def test_camp_macro_randeaza_type_date():
"""Macro `camp` din _macros.html trebuie sa suporte tip='date' si sa
randeze <input type='date'> fara a strica tip='text' (default).
Inainte de US-005, macros.html nu are macro `camp` -> test RED.
"""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)))
# Randare directa a macro-ului camp din _macros.html.
tmpl = env.from_string(
"{% from '_macros.html' import camp %}"
"{{ camp('data_prestatie', 'Data prestatie', '2026-06-15', tip='date') }}"
)
html = tmpl.render()
# Trebuie sa contina type="date".
assert 'type="date"' in html, \
"macro camp cu tip='date' trebuie sa randeze <input type='date'>"
assert 'name="data_prestatie"' in html, \
"macro camp trebuie sa randeze input cu name corect"
# Verifica ca tip='text' (default) inca functioneaza.
tmpl_text = env.from_string(
"{% from '_macros.html' import camp %}"
"{{ camp('nr_inmatriculare', 'Nr inmatriculare', 'B100AA') }}"
)
html_text = tmpl_text.render()
assert 'type="text"' in html_text, \
"macro camp fara tip explicit trebuie sa randeze type='text' (default neschimbat)"
assert 'type="date"' not in html_text, \
"macro camp fara tip='date' NU trebuie sa randeze type='date'"

421
tests/test_web_mapare_op.py Normal file
View File

@@ -0,0 +1,421 @@
"""Teste US-004 — un singur „Salveaza maparile" pe panoul de operatii nemapate.
Ruta noua: POST /_import/{id}/mapare-operatii (plural)
- primeste perechi (cod_op_service, cod_prestatie) ca liste paralele
- apeleaza save_mapping pentru fiecare pereche cu cod ales (reuse exact)
- ignora perechile cu cod_prestatie gol (nu eroare, nu salvare)
- D#12: validare per-item — cod invalid -> skip + sumar, restul salvate
- O singura _web_compute_preview + re-randare #import-section la final
- CSRF + scoped sesiune + guard batch committed (409) pastrate
"""
from __future__ import annotations
import csv as csv_mod
import io
import os
import re
import tempfile
import pytest
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
# Mod dev: fallback cont 1, fara login/CSRF (ca in test_import_mapare_operatie).
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
get_settings.cache_clear()
# ------------------------------------------------------------------ #
# Helpers de setup #
# ------------------------------------------------------------------ #
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final",
"Cod operatie", "Denumire"]
_CANON = ["vin", "nr_inmatriculare", "data_prestatie", "odometru_final",
"operatie", "denumire_op"]
# Doua operatii distincte: OP-REV si OP-FR
_ROWS_2OPS = [
["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "OP-REV", "Revizie periodica"],
["WVWZZZ1KZAW002222", "CJ200AB", "2026-05-20", "98765", "OP-REV", "Revizie periodica"],
["WVWZZZ1KZAW003333", "IS300CD", "2026-04-10", "50000", "OP-FR", "Franare"],
]
# O singura operatie
_ROWS_1OP = [
["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "OP-REV", "Revizie periodica"],
["WVWZZZ1KZAW002222", "CJ200AB", "2026-05-20", "98765", "OP-REV", "Revizie periodica"],
]
def _csv_bytes(header, rows, sep=";") -> bytes:
buf = io.StringIO()
w = csv_mod.writer(buf, delimiter=sep)
w.writerow(header)
for r in rows:
w.writerow(r)
return buf.getvalue().encode("utf-8")
def _upload(client, rows=None) -> int:
"""Incarca fisier CSV si intoarce import_id."""
rows = _ROWS_2OPS if rows is None else rows
r = client.post(
"/_import/upload",
files={"file": ("t.csv", _csv_bytes(_HEADER, rows), "text/csv")},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"form mapare-coloane lipsa: {r.text[:300]}"
return int(m.group(1))
def _map_columns(client, import_id, canon=None):
return client.post(
f"/_import/{import_id}/mapare-coloane",
data={
"colname": _HEADER,
"canon": canon or _CANON,
"format_data": "YYYY-MM-DD",
},
)
def _get_batch_counts(import_id):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT ok, needs_mapping FROM import_batches WHERE id=?", (import_id,)
).fetchone()
finally:
conn.close()
def _get_row_statuses(import_id):
from app.db import get_connection
conn = get_connection()
try:
rows = conn.execute(
"SELECT resolved_status FROM import_rows WHERE batch_id=? ORDER BY row_index",
(import_id,),
).fetchall()
return [r["resolved_status"] for r in rows]
finally:
conn.close()
def _get_mapping(cod_op_service, account_id=1):
from app.db import get_connection
conn = get_connection()
try:
return conn.execute(
"SELECT cod_prestatie FROM operations_mapping WHERE account_id=? AND cod_op_service=?",
(account_id, cod_op_service),
).fetchone()
finally:
conn.close()
# ------------------------------------------------------------------ #
# 1. Salveaza multiple operatii intr-un singur POST #
# ------------------------------------------------------------------ #
def test_mapare_operatii_salveaza_multiple_intr_un_post(client):
"""POST mapare-operatii cu 2 operatii alese -> ambele salvate, randurile trec la ok."""
import_id = _upload(client)
r = _map_columns(client, import_id)
assert r.status_code == 200
# Inainte: 0 ok, 3 needs_mapping
b = _get_batch_counts(import_id)
assert b["needs_mapping"] == 3
assert b["ok"] == 0
# Un singur POST cu ambele operatii
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV", "OP-FR"],
"cod_prestatie": ["OE-3", "OE-1"],
},
)
assert rm.status_code == 200, rm.text
# Raspuns e preview re-randat cu #import-section
assert "import-section" in rm.text
# Ambele mapari persistate
assert _get_mapping("OP-REV") is not None
assert _get_mapping("OP-REV")["cod_prestatie"] == "OE-3"
assert _get_mapping("OP-FR") is not None
assert _get_mapping("OP-FR")["cod_prestatie"] == "OE-1"
# Toate randurile trecute la ok
b2 = _get_batch_counts(import_id)
assert b2["ok"] == 3, b2
assert b2["needs_mapping"] == 0, b2
statuses = _get_row_statuses(import_id)
assert all(s == "ok" for s in statuses), statuses
def test_panoul_mapare_are_un_singur_form(client):
"""Preview-ul randeaza panoul de mapare cu un singur <form> si un buton Salveaza maparile."""
import_id = _upload(client)
r = _map_columns(client, import_id)
assert r.status_code == 200
# Ruta noua mapare-operatii (plural) prezenta in form
assert f"/_import/{import_id}/mapare-operatii" in r.text
# Un singur buton Salveaza
assert "Salveaza maparile" in r.text
# NU apare ruta singular mapare-operatie ca target de form (panoul unificat)
# (poate apare ca ruta pastrata, dar nu ca hx-post al formularului de mapare in bulk)
# ------------------------------------------------------------------ #
# 2. Ignora operatiile fara cod ales #
# ------------------------------------------------------------------ #
def test_mapare_operatii_ignora_randuri_neselectate(client):
"""Operatia cu cod_prestatie gol e ignorata (nu eroare, nu salvare)."""
import_id = _upload(client, rows=_ROWS_2OPS)
_map_columns(client, import_id)
# OP-REV cu cod ales, OP-FR fara cod ales (string gol = "— alege cod RAR —")
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV", "OP-FR"],
"cod_prestatie": ["OE-3", ""],
},
)
assert rm.status_code == 200, rm.text
# OP-REV salvat
assert _get_mapping("OP-REV") is not None
# OP-FR nesal vat (nu eroare, nu mapare)
assert _get_mapping("OP-FR") is None
# Randurile OP-REV (2) sunt ok, OP-FR (1) raman needs_mapping
b = _get_batch_counts(import_id)
assert b["ok"] == 2, b
assert b["needs_mapping"] == 1, b
# Panoul mai arata OP-FR (inca nemapat)
assert "OP-FR" in rm.text
def test_mapare_operatii_fara_nicio_selectie_nu_eroare(client):
"""POST cu toate cod_prestatie goale -> nici o eroare, nici o salvare, preview re-randat."""
import_id = _upload(client, rows=_ROWS_1OP)
_map_columns(client, import_id)
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV"],
"cod_prestatie": [""],
},
)
assert rm.status_code == 200, rm.text
# Nicio mapare salvata
assert _get_mapping("OP-REV") is None
# Randurile raman needs_mapping
b = _get_batch_counts(import_id)
assert b["needs_mapping"] == 2, b
assert b["ok"] == 0, b
# ------------------------------------------------------------------ #
# 3. Re-rezolva randurile blocate cu needs_mapping #
# ------------------------------------------------------------------ #
def test_mapare_operatii_re_rezolva_blocatele(client):
"""Operatiile cu cod ales trec din needs_mapping la ok (re-rezolvare imediata)."""
import_id = _upload(client, rows=_ROWS_1OP)
_map_columns(client, import_id)
# Inainte: 2 needs_mapping
b = _get_batch_counts(import_id)
assert b["needs_mapping"] == 2, b
assert b["ok"] == 0, b
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV"],
"cod_prestatie": ["OE-3"],
},
)
assert rm.status_code == 200
# Dupa: 2 ok, 0 needs_mapping
b2 = _get_batch_counts(import_id)
assert b2["ok"] == 2, b2
assert b2["needs_mapping"] == 0, b2
statuses = _get_row_statuses(import_id)
assert all(s == "ok" for s in statuses), statuses
# Preview randat nu mai arata panoul de mapare
assert "Operatii de mapat la cod RAR" not in rm.text
# ------------------------------------------------------------------ #
# 4. D#12 — validare per-item: cod invalid skip + sumar, restul ok #
# ------------------------------------------------------------------ #
def test_mapare_operatii_cod_invalid_skip_salveaza_restul(client):
"""D#12: daca un cod ales e invalid (1 din 2), skip-ul + sumar, celalalt salvat, 1 re-render."""
import_id = _upload(client, rows=_ROWS_2OPS)
_map_columns(client, import_id)
# OP-REV cod valid, OP-FR cod inexistent
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV", "OP-FR"],
"cod_prestatie": ["OE-3", "COD-INEXISTENT"],
},
)
assert rm.status_code == 200, rm.text
# OP-REV salvat (codul valid)
assert _get_mapping("OP-REV") is not None
assert _get_mapping("OP-REV")["cod_prestatie"] == "OE-3"
# OP-FR nesalvat (cod invalid)
assert _get_mapping("OP-FR") is None
# Sumar in mesaj: cod invalid mentionat
assert "COD-INEXISTENT" in rm.text or "necunoscut" in rm.text.lower()
# Randurile OP-REV (2) ok, OP-FR (1) inca needs_mapping
b = _get_batch_counts(import_id)
assert b["ok"] == 2, b
assert b["needs_mapping"] == 1, b
# O singura re-randare (200, nu redirect, nu multiple #import-section)
assert rm.text.count("import-section") >= 1
# ------------------------------------------------------------------ #
# 5. Guard batch committed (409) #
# ------------------------------------------------------------------ #
def test_mapare_operatii_batch_committed_409(client):
"""Batch deja comis -> 409 Conflict."""
import_id = _upload(client, rows=_ROWS_1OP)
_map_columns(client, import_id)
# Marcheaza batch-ul ca committed direct in DB
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE import_batches SET status='committed' WHERE id=?", (import_id,)
)
conn.commit()
finally:
conn.close()
rm = client.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV"],
"cod_prestatie": ["OE-3"],
},
)
assert rm.status_code == 409, rm.text
# ------------------------------------------------------------------ #
# 6. Guard scoped sesiune (404 cross-account) #
# ------------------------------------------------------------------ #
def test_mapare_operatii_scoped_404_alt_cont(monkeypatch):
"""Import apartinand altui cont -> 404 (scoping corect)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "scope.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app, follow_redirects=False) as c:
conn = get_connection()
try:
acct_a = create_account(conn, "ServiceA", active=True)
create_user(conn, acct_a, "a@test.com", "parolasecreta10")
acct_b = create_account(conn, "ServiceB", active=True)
create_user(conn, acct_b, "b@test.com", "parolasecreta10")
finally:
conn.close()
def _login_and_get_csrf(c, email, password="parolasecreta10"):
resp = c.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
csrf = m.group(1)
c.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
# Get a fresh CSRF token for next request
resp2 = c.get("/?tab=import")
m2 = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp2.text)
return m2.group(1) if m2 else csrf
# Login cu cont A, upload fisier (batch apartine cont A)
csrf_a = _login_and_get_csrf(c, "a@test.com")
r = c.post(
"/_import/upload",
files={"file": ("t.csv", _csv_bytes(_HEADER, _ROWS_1OP), "text/csv")},
data={"csrf_token": csrf_a},
)
assert r.status_code == 200, r.text
m2 = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m2
import_id = int(m2.group(1))
# Extrage CSRF din raspuns pentru mapare-coloane
m_csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text)
csrf_for_map = m_csrf.group(1) if m_csrf else csrf_a
c.post(f"/_import/{import_id}/mapare-coloane",
data={"colname": _HEADER, "canon": _CANON, "format_data": "YYYY-MM-DD",
"csrf_token": csrf_for_map})
# Login cu cont B, incearca mapare pe batch-ul lui A
csrf_b = _login_and_get_csrf(c, "b@test.com")
rm = c.post(
f"/_import/{import_id}/mapare-operatii",
data={
"cod_op_service": ["OP-REV"],
"cod_prestatie": ["OE-3"],
"csrf_token": csrf_b,
},
)
assert rm.status_code == 404, f"expected 404 got {rm.status_code}: {rm.text[:200]}"
get_settings.cache_clear()

View File

@@ -0,0 +1,165 @@
"""Teste US-003 — pasul „Potriveste coloanele" arata antet + prima inregistrare.
Acceptance criteria:
- Deasupra/langa randurile de mapare, un mic tabel orizontal:
cap de tabel = numele coloanelor din fisier,
rand = valorile primei inregistrari (truncate; `title` pe valoarea integrala).
- Foloseste .tablewrap pentru scroll orizontal pe mobil.
- Fiecare coloana din cap ramane asociata vizual cu select-ul ei de mapare.
- Fisier fara randuri de date -> doar capul de tabel + mesaj explicit
„antet fara randuri de date" (D#11) + butonul „Continua" dezactivat.
"""
from __future__ import annotations
import io
import os
import tempfile
import pytest
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _make_xlsx_bytes(rows: list[dict]) -> bytes:
"""Construieste un xlsx minimal cu openpyxl."""
openpyxl = pytest.importorskip("openpyxl")
wb = openpyxl.Workbook()
ws = wb.active
if not rows:
return b""
headers = list(rows[0].keys())
ws.append(headers)
for row in rows:
ws.append([row.get(h) for h in headers])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _make_xlsx_only_header(headers: list[str]) -> bytes:
"""Construieste un xlsx cu doar antet (fara randuri de date)."""
openpyxl = pytest.importorskip("openpyxl")
wb = openpyxl.Workbook()
ws = wb.active
ws.append(headers)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
_COLOANE = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
_SAMPLE_ROWS = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr inmatriculare": "B001TST",
"Data prestatie": "15.06.2026",
"Odometru final": "123456",
"Operatie": "Revizie",
},
{
"VIN": "WVWZZZ1KZAW000456",
"Nr inmatriculare": "B002TST",
"Data prestatie": "16.06.2026",
"Odometru final": "200000",
"Operatie": "Revizie",
},
]
def _upload_csv_and_get_mapare(client, rows=None, headers=None) -> tuple[int, str]:
"""Incarca un xlsx si intoarce (import_id, html_text) din pasul de mapare coloane."""
if headers is not None:
# Fisier cu doar antet
xlsx = _make_xlsx_only_header(headers)
else:
xlsx = _make_xlsx_bytes(rows or _SAMPLE_ROWS)
r = client.post(
"/_import/upload",
files={"file": ("test.xlsx", xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
)
return r.status_code, r.text
# --------------------------------------------------------------------------- #
# test_mapcoloane_arata_cap_tabel_coloane #
# --------------------------------------------------------------------------- #
def test_mapcoloane_arata_cap_tabel_coloane(client):
"""Pasul de mapare coloane afiseaza un tabel orizontal cu antetul fisierului.
Fiecare coloana din fisier trebuie sa apara ca <th> in capul tabelului.
Tabelul trebuie inconjurat de .tablewrap pentru scroll orizontal.
"""
status, html = _upload_csv_and_get_mapare(client)
assert status == 200
# Tabelul de preview al antetului trebuie sa fie prezent
assert "tablewrap" in html, "Trebuie .tablewrap pentru scroll orizontal"
# Fiecare coloana din fisier trebuie sa apara ca <th> in tabel
for col in _COLOANE:
assert f"<th" in html, "Trebuie elemente <th> in capul tabelului"
assert col in html, f"Coloana '{col}' trebuie sa apara in antetul tabelului"
# Tabelul de preview (cu class sau id distinct pentru antet preview)
assert "preview-antet" in html, "Tabelul de preview trebuie sa aiba class/id 'preview-antet'"
# --------------------------------------------------------------------------- #
# test_mapcoloane_arata_valori_prima_inregistrare #
# --------------------------------------------------------------------------- #
def test_mapcoloane_arata_valori_prima_inregistrare(client):
"""Pasul de mapare coloane afiseaza valorile primei inregistrari din fisier.
Valorile din primul rand de date trebuie sa apara in tabel.
"""
status, html = _upload_csv_and_get_mapare(client, rows=_SAMPLE_ROWS)
assert status == 200
# Valorile primei inregistrari trebuie sa apara
assert "WVWZZZ1KZAW000123" in html, "VIN-ul primei inregistrari trebuie sa apara"
assert "B001TST" in html, "Nr inmatriculare al primei inregistrari trebuie sa apara"
assert "15.06.2026" in html, "Data prestatiei primei inregistrari trebuie sa apara"
assert "123456" in html, "Odometrul primei inregistrari trebuie sa apara"
# Valorile celui de-al doilea rand NU trebuie sa apara ca rand de date (numai primul rand)
# (Nota: pot aparea ca exemple in alt context, dar randul de date e primul)
# --------------------------------------------------------------------------- #
# test_mapcoloane_fara_randuri_degradeaza #
# --------------------------------------------------------------------------- #
def test_mapcoloane_fara_randuri_degradeaza(client):
"""Fisier xlsx cu antet dar fara randuri de date → degradare grijulie.
Trebuie sa:
- Nu crape (status 200)
- Arate capul de tabel cu coloanele
- Arate mesajul explicit 'antet fara randuri de date'
- Dezactiveze butonul 'Salveaza si continua' (disabled)
"""
status, html = _upload_csv_and_get_mapare(client, headers=_COLOANE)
assert status == 200, f"Status neasteptat: {status}"
# Nu trebuie sa crape — formular de mapare afisat
assert "Mapare coloane" in html, "Formularul de mapare trebuie afisat"
# Capul de tabel cu coloanele trebuie afisat
for col in _COLOANE:
assert col in html, f"Coloana '{col}' trebuie sa apara in antet chiar si fara date"
# Mesaj explicit despre lipsa datelor
assert "antet fara randuri de date" in html.lower() or "fara randuri de date" in html.lower(), \
"Trebuie mesaj explicit despre lipsa randurilor de date"
# Butonul Continua trebuie dezactivat
assert "disabled" in html, "Butonul 'Salveaza si continua' trebuie dezactivat cand nu sunt randuri"

View File

@@ -0,0 +1,182 @@
"""Teste US-007 (PRD 5.12): preview compact — scoatere coloana "Verificat?" + VIN compact.
TDD RED: testele sunt scrise inainte de implementare.
Scenarii:
1. Preview NU contine coloana "Verificat?" (col-verificat / antet).
2. VIN nu se sparge pe verticala (white-space controlat in templateul randului).
"""
from __future__ import annotations
import io
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client fara autentificare web obligatorie (cont 1 implicit)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pc.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()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _seed_op1(account_id: int = 1) -> None:
"""Semeaza nomenclator + mapare OP-1 → R-FRANE."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
"VALUES ('R-FRANE','Reparatie frane')"
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, 'OP-1', 'R-FRANE', 1)",
(account_id,),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv as _csv
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")
_SAMPLE_ROWS = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr": "B001TST",
"Data": "2026-06-10",
"KM": "123456",
"Operatie": "OP-1",
},
]
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _get_csrf(client: TestClient) -> str:
r = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
def _upload_and_preview(client: TestClient, rows: list[dict] | None = None,
format_data: str = "YYYY-MM-DD") -> int:
"""Upload CSV + salveaza mapare → preview. Intoarce import_id."""
rows = rows or _SAMPLE_ROWS
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _get_csrf(client)
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": format_data,
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
return iid
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_preview_fara_coloana_verificat(client):
"""Tabelul de preview NU contine coloana 'Verificat?' (col-verificat).
US-007 (PRD 5.12): coloana 'Verificat?' este eliminata din preview;
antetul si celulele scad la 8 coloane (fara col-verificat).
Verifica:
- Antetul tabelului NU contine 'Verificat?' ca text
- NU exista elemente cu clasa 'col-verificat' in HTML-ul de preview
- NU exista input[name='reviewed_rows'] (checkboxele au fost eliminate)
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
# Coloana "Verificat?" trebuie eliminata
assert "Verificat?" not in html, \
"Coloana 'Verificat?' nu trebuie sa apara in antetul tabelului de preview"
assert "col-verificat" not in html, \
"Clasa 'col-verificat' nu trebuie sa existe in HTML-ul de preview"
# Checkboxele reviewed_rows trebuie eliminate
assert 'name="reviewed_rows"' not in html, \
"Input[name='reviewed_rows'] (checkbox) nu trebuie sa existe in preview"
def test_preview_vin_nu_se_sparge_pe_verticala(client):
"""VIN-ul din coloana Vehicul nu se mai sparge pe verticala.
US-007 (PRD 5.12): randuri compacte — VIN cu white-space controlat
(white-space:nowrap sau min-width pe coloana), fara overflow orizontal.
Verifica:
- Divul cu VIN scurt are white-space:nowrap (previne ruperea pe linie noua)
- SAU coloana col-vehicul are o latime/min-width adecvata
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
# Verifica ca VIN-ul are white-space:nowrap in div-ul de VIN scurt
# Template-ul _preview_rand.html trebuie sa aiba:
# <div class="muted" style="...white-space:nowrap...">{{ row.prez.vin_scurt }}</div>
assert "white-space:nowrap" in html or "white-space: nowrap" in html, \
"VIN-ul din preview trebuie sa aiba white-space:nowrap pentru a preveni ruperea pe verticala"

View File

@@ -0,0 +1,541 @@
"""Teste US-006 (PRD 5.12): editarea unui rand de preview deschide MODALUL global,
nu un rand inline (tr.preview-edit).
TDD RED: testele sunt scrise inainte de implementare.
Scenarii:
1. GET editare-modal → fragment pentru #detaliu-modal-body (NU tr.preview-edit).
2. POST editeaza cu succes → HX-Trigger-After-Settle: inchideModal + OOB rand+contoare.
3. Buton Anuleaza = inchidere modal, fara cerere catre /_import/.../rand/{i} (R5).
4. Scoping 404 cross-account.
5. Guard committed → 409.
6. INVARIANT CRITIC (R2): tabela submissions NEATINSA dupa editare preview.
"""
from __future__ import annotations
import io
import os
import re
import tempfile
import pytest
from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- #
# Fixtures #
# --------------------------------------------------------------------------- #
@pytest.fixture()
def client(monkeypatch):
"""Client fara autentificare web obligatorie (conte 1 implicit)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pe.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()
@pytest.fixture()
def client_auth(monkeypatch):
"""Client cu autentificare web obligatorie (pentru teste de scoping cross-account)."""
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "pe_auth.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.crypto import reset_cache
reset_cache()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
reset_cache()
# --------------------------------------------------------------------------- #
# Helpere #
# --------------------------------------------------------------------------- #
def _seed_op1(account_id: int = 1) -> None:
"""Semeaza nomenclator + mapare OP-1 → R-FRANE (auto_send=1)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) "
"VALUES ('R-FRANE','Reparatie frane')"
)
conn.execute(
"INSERT OR IGNORE INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, 'OP-1', 'R-FRANE', 1)",
(account_id,),
)
conn.commit()
finally:
conn.close()
def _csv_bytes(rows: list[dict], sep: str = ";") -> bytes:
import csv as _csv
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")
_SAMPLE_ROWS = [
{
"VIN": "WVWZZZ1KZAW000123",
"Nr": "B001TST",
"Data": "2026-06-10",
"KM": "123456",
"Operatie": "OP-1",
},
{
"VIN": "WVWZZZ1KZAW000456",
"Nr": "B002TST",
"Data": "2026-06-11",
"KM": "200000",
"Operatie": "OP-1",
},
]
_MAP_COLS = {
"VIN": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
def _get_csrf(client: TestClient) -> str:
"""Obtine CSRF token din sesiunea curenta prin GET pe pagina principala."""
r = client.get("/")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', r.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', r.text)
return m.group(1) if m else ""
def _upload_and_preview(client: TestClient, rows: list[dict] | None = None) -> int:
"""Upload CSV + salveaza mapare coloane → preview. Intoarce import_id.
Obtine CSRF inainte de fiecare POST (necesar cand AUTOPASS_WEB_AUTH_REQUIRED=true).
"""
rows = rows or _SAMPLE_ROWS
csv_data = _csv_bytes(rows)
csrf = _get_csrf(client)
r = client.post(
"/_import/upload",
files={"file": ("test.csv", io.BytesIO(csv_data), "text/csv")},
data={"csrf_token": csrf},
)
assert r.status_code == 200, r.text
m = re.search(r"/_import/(\d+)/mapare-coloane", r.text)
assert m, f"import_id negasit in raspuns: {r.text[:300]}"
iid = int(m.group(1))
colnames = list(rows[0].keys())
canons = [_MAP_COLS[c] for c in colnames]
csrf2 = _get_csrf(client)
r2 = client.post(f"/_import/{iid}/mapare-coloane", data={
"colname": colnames,
"canon": canons,
"format_data": "YYYY-MM-DD",
"csrf_token": csrf2,
})
assert r2.status_code == 200, r2.text
return iid
def _login(client: TestClient, email: str, password: str = "parola123secure") -> None:
"""Login in sesiune web."""
resp = client.get("/login")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) or \
re.search(r'value="([^"]+)"\s+name="csrf_token"', resp.text)
assert m, "csrf_token negasit in pagina login"
resp = client.post("/login", data={
"email": email,
"parola": password,
"csrf_token": m.group(1),
})
assert resp.status_code == 303, f"Login esuat: {resp.status_code}"
def _create_user(email: str, password: str = "parola123secure") -> int:
"""Creeaza cont + user. Intoarce account_id."""
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user
conn = get_connection()
try:
acct = create_account(conn, "TestFirma", active=True)
create_user(conn, acct, email, password)
return acct
finally:
conn.close()
def _count_submissions() -> int:
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT COUNT(*) FROM submissions").fetchone()[0]
finally:
conn.close()
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_editeaza_preview_serveste_fragment_modal(client):
"""GET /_import/{id}/rand/0/editare-modal randeaza fragment pentru modal,
NU tr.preview-edit (randul inline).
Verifica:
- Raspuns 200
- Contine formular cu hx-post catre /editeaza
- Contine campurile de editare (data_prestatie, vin, etc.)
- NU contine clasa 'preview-edit' (randul inline eliminat)
- Contine id="detaliu-modal-titlu" (heading pentru aria-labelledby)
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r.status_code == 200, r.text
html = r.text
# Formular cu actiune POST catre editeaza
assert f'hx-post="/_import/{iid}/rand/0/editeaza"' in html, \
"Fragmentul modal trebuie sa aiba form cu hx-post catre editeaza"
# Campurile de editare prezente
assert 'name="data_prestatie"' in html, "Camp data_prestatie lipsa"
assert 'name="vin"' in html, "Camp vin lipsa"
assert 'name="nr_inmatriculare"' in html, "Camp nr_inmatriculare lipsa"
# NU randul inline de tip preview-edit
assert "preview-edit" not in html, \
"Clasa 'preview-edit' (randul inline) nu trebuie sa existe in fragmentul modal"
# Heading pentru aria-labelledby al modalului
assert 'id="detaliu-modal-titlu"' in html, \
"Fragmentul modal trebuie sa aiba id='detaliu-modal-titlu'"
# Nu contine #confirm-form (inputurile nu sunt legate de formularul de confirmare)
assert 'id="confirm-form"' not in html, \
"Fragmentul modal nu trebuie sa contina confirm-form"
def test_salvare_preview_inchide_modal_si_oob_rand(client):
"""POST /_import/{id}/rand/0/editeaza cu date valide → HX-Trigger-After-Settle: inchideModal
+ OOB pe rand (#preview-row-0) si contoare (#preview-rezumat).
Verifica:
- Status 200
- Header HX-Trigger-After-Settle contine 'inchideModal'
- Raspuns contine OOB pentru randul actualizat (hx-swap-oob prezent)
- Raspuns contine OOB pentru rezumat (#preview-rezumat)
- NU re-randeaza intreaga sectiune (#import-section absent)
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "2026-06-15",
})
assert r.status_code == 200, r.text
html = r.text
# Header de inchidere modal
trigger = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" in trigger, \
f"Header HX-Trigger-After-Settle trebuie sa contina 'inchideModal', gasit: '{trigger}'"
# OOB pe randul actualizat
assert 'id="preview-row-0"' in html, \
"Raspunsul trebuie sa contina randul actualizat (#preview-row-0)"
assert "hx-swap-oob" in html, \
"Raspunsul trebuie sa contina OOB swap"
# OOB pe rezumatul stari
assert 'id="preview-rezumat"' in html, \
"Raspunsul trebuie sa contina OOB pe #preview-rezumat"
# NU re-randeaza intreaga sectiune de import
assert 'id="import-section"' not in html, \
"Editarea randului NU trebuie sa re-randeze intreaga sectiune #import-section"
def test_anuleaza_nu_lasa_rand_orfan(client):
"""Butonul Anuleaza din fragmentul modal inchide modalul fara cerere catre server.
Reproduce eroarea htmx 'TypeError: Cannot read properties of null
(reading htmx-internal-data)' (R5) — generata de ramura editing inline care
facea un GET pe /_import/.../rand/{i} la Anuleaza, iar dupa stergerea randului
din DOM, htmx nu mai gasea tinta.
Verifica la nivel de markup: butonul Anuleaza NU are hx-get catre rand.
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/rand/0/editare-modal")
assert r.status_code == 200
html = r.text
# Butonul Anuleaza nu trebuie sa aiba hx-get catre ruta randului
# (eroarea htmx aparea exact din aceasta cerere)
# Cauta pattern-ul care ar produce eroarea: hx-get catre rand display (fara sufix)
assert f'hx-get="/_import/{iid}/rand/0"' not in html, \
"Butonul Anuleaza NU trebuie sa faca GET pe /_import/.../rand/0 (produce eroare htmx)"
# Fragmentul modal trebuie sa aiba un mecanism de inchidere fara request
# (data-modal-close sau onclick cu window.inchideDetaliu)
has_modal_close = "data-modal-close" in html or "inchideDetaliu" in html
assert has_modal_close, \
"Butonul Anuleaza trebuie sa inchida modalul local (data-modal-close sau inchideDetaliu)"
def test_editare_preview_scoped_404_alt_cont(client_auth):
"""GET si POST editare-modal pe un rand al altui cont → 404 (scoping JOIN).
Nu confirmam existenta rand-ului cross-account (acelasi mesaj ca inexistent).
"""
from app.db import get_connection
from app.accounts import create_account
from app.users import create_user
# Creeaza doua conturi
conn = get_connection()
try:
acct1 = create_account(conn, "Firma A", active=True)
create_user(conn, acct1, "user_a@test.com", "parola123secure")
acct2 = create_account(conn, "Firma B", active=True)
create_user(conn, acct2, "user_b@test.com", "parola123secure")
finally:
conn.close()
# Seed nomenclator pentru ambele conturi (global)
_seed_op1(acct1)
# Login ca user A si creeaza batch
_login(client_auth, "user_a@test.com")
iid = _upload_and_preview(client_auth)
# Login ca user B si incearca sa acceseze batch-ul lui A
_login(client_auth, "user_b@test.com")
r_get = client_auth.get(f"/_import/{iid}/rand/0/editare-modal")
assert r_get.status_code == 404, \
f"GET editare-modal cross-account trebuie sa returneze 404, got {r_get.status_code}"
r_post = client_auth.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "2026-06-20",
"csrf_token": "dummy",
})
assert r_post.status_code in (403, 404), \
f"POST editeaza cross-account trebuie sa returneze 403/404, got {r_post.status_code}"
def test_editare_batch_committed_409(client):
"""POST editeaza pe un batch deja comis → 409.
Guard committed: batch trimis ireversibil nu mai poate fi editat
(editarea nu mai are efect downstream).
"""
from app.db import get_connection
_seed_op1()
iid = _upload_and_preview(client)
# Marcheaza batch ca committed
conn = get_connection()
try:
conn.execute("UPDATE import_batches SET status='committed' WHERE id=?", (iid,))
conn.commit()
finally:
conn.close()
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "2026-06-20",
})
assert r.status_code == 409, \
f"Editare pe batch committed trebuie sa returneze 409, got {r.status_code}"
def test_submissions_neatins_dupa_editare_preview(client):
"""INVARIANT CRITIC (R2): dupa editarea unui rand de preview, tabela submissions
ramane NEATINSA.
Editarea preview = override-only pe import_rows.override_json.
NU re-queue, NU insereaza, NU modifica submissions.
"""
_seed_op1()
iid = _upload_and_preview(client)
# Numara submissions inainte de editare
n_before = _count_submissions()
# Editeaza randul 0
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "2026-06-15",
})
assert r.status_code == 200, r.text
# Verifica ca submissions nu a fost atinsa
n_after = _count_submissions()
assert n_after == n_before, (
f"Editarea preview a atins tabela submissions! "
f"Inainte: {n_before}, dupa: {n_after}. "
"Editarea trebuie sa fie override-only (import_rows.override_json), NU re-queue."
)
def test_eroare_validare_modalul_ramane_deschis(client):
"""POST editeaza cu data invalida → raspuns 200 cu formularul si erorile per-camp.
La eroare de validare, modalul ramane deschis cu valorile introduse si mesajele
de eroare per-camp. NU emite inchideModal.
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={
"data_prestatie": "data-invalida",
})
assert r.status_code == 200, r.text
html = r.text
# Formularul trebuie sa fie prezent (modalul ramane deschis)
assert f'hx-post="/_import/{iid}/rand/0/editeaza"' in html, \
"La eroare de validare, formularul trebuie sa ramana in modal"
# NU emite inchideModal (modalul ramane deschis)
trigger = r.headers.get("HX-Trigger-After-Settle", "")
assert "inchideModal" not in trigger, \
"La eroare de validare, NU trebuie emis inchideModal"
# Valoarea invalida pastrata (pentru corectie usoara)
assert "data-invalida" in html, "Valoarea invalida trebuie pastrata in formular"
# Mesaj de eroare per-camp
assert "data" in html.lower(), "Trebuie sa existe mesaj de eroare legat de data"
def test_preview_buton_editeaza_tinteste_detaliu_modal_body(client):
"""Butonul 'Editeaza' din tabelul de preview tinteste #detaliu-modal-body,
nu randul inline (#preview-row-N).
Verifica ca in fragmentul preview, butonul de editare are:
- hx-target="#detaliu-modal-body"
- URL catre endpoint-ul de editare-modal
"""
_seed_op1()
iid = _upload_and_preview(client)
r = client.get(f"/_import/{iid}/preview")
assert r.status_code == 200, r.text
html = r.text
# Butonul Editeaza trebuie sa tinteasca detaliu-modal-body
assert 'hx-target="#detaliu-modal-body"' in html, \
"Butonul Editeaza trebuie sa aiba hx-target='#detaliu-modal-body'"
# URL de editare prezent (editare-modal sau editare care redirecteaza la modal)
assert f"/_import/{iid}/rand/0/editare" in html, \
"URL-ul de editare trebuie sa fie prezent in preview"
# NU mai exista clasa preview-edit pe randuri (ramura inline eliminata)
assert 'class="preview-edit"' not in html, \
"Clasa 'preview-edit' (randul inline) nu trebuie sa fie in preview dupa US-006"
# NU mai exista atribut data-editing="1" pe randuri (mutual-exclusion eliminata)
assert 'data-editing="1"' not in html, \
"Atributul data-editing='1' nu trebuie sa fie pe randuri dupa US-006"
# --------------------------------------------------------------------------- #
# Teste markup Bug 2: btn-editeaza deschide modalul prin open() global (B2) #
# --------------------------------------------------------------------------- #
def test_btn_editeaza_nu_are_js_inline_open_modal():
"""Bug B2 (markup): butonul .btn-editeaza din _preview_rand.html NU trebuie
sa mai deschida modalul cu JS inline (removeAttribute('hidden')).
Deschiderea trebuie sa treaca prin open(triggerRow) din base.html, altfel
<main> nu primeste inert/aria-hidden si focus-trap-ul nu e instalat.
"""
import pathlib
template = (
pathlib.Path(__file__).parent.parent
/ "app/web/templates/_preview_rand.html"
)
html = template.read_text(encoding="utf-8")
# Gasim sectiunea butonului .btn-editeaza
idx = html.find("btn-editeaza")
assert idx >= 0, "Butonul .btn-editeaza nu a fost gasit in template"
btn_section = html[idx:]
# NU trebuie sa existe inline JS care face removeAttribute('hidden') pe modal
assert "removeAttribute('hidden')" not in btn_section, (
"Butonul .btn-editeaza NU trebuie sa mai aiba JS inline care face "
"removeAttribute('hidden') — deschiderea trebuie sa treaca prin "
"open(triggerRow) din base.html pentru focus-trap + inert pe <main>."
)
# NU trebuie sa existe hx-on:htmx:before-request inline pe btn-editeaza
# (mecanismul de deschidere trebuie sa fie in handler-ul global din base.html)
assert "hx-on:htmx:before-request" not in btn_section.split("</button>")[0] \
and "hx-on::before-request" not in btn_section.split("</button>")[0], (
"Butonul .btn-editeaza NU trebuie sa aiba hx-on:htmx:before-request inline. "
"Deschiderea modalului trebuie sa fie in handler-ul global din base.html."
)
def test_base_html_deschide_modal_pentru_btn_editeaza():
"""Bug B2 (markup): handler-ul htmx:beforeRequest din base.html trebuie sa
apeleze open() si pentru butonul .btn-editeaza (nu doar pentru .trimitere-row).
Fara aceasta generalizare, <main> nu primeste inert/aria-hidden, focus-trap-ul
nu e instalat si focusul nu e readus pe buton la inchidere (US-006).
"""
import pathlib
base = (
pathlib.Path(__file__).parent.parent
/ "app/web/templates/base.html"
)
html = base.read_text(encoding="utf-8")
# Handler-ul htmx:beforeRequest trebuie sa tina cont de btn-editeaza
# SAU de hx-target="#detaliu-modal-body" (oricare din cele doua abordari e OK)
handler_idx = html.find("htmx:beforeRequest")
assert handler_idx >= 0, "Handler-ul htmx:beforeRequest nu a fost gasit in base.html"
# Cauta in zona handler-ului (urmatoarele 500 de caractere)
handler_zone = html[handler_idx:handler_idx + 500]
handles_btn_editeaza = (
"btn-editeaza" in handler_zone
or 'detaliu-modal-body' in handler_zone
or "editare-modal" in handler_zone
)
assert handles_btn_editeaza, (
"Handler-ul htmx:beforeRequest din base.html trebuie sa trateze si butonul "
".btn-editeaza (sau sa verifice hx-target='#detaliu-modal-body') pentru a "
"apela open() si instala focus-trap-ul (US-006)."
)

View File

@@ -287,4 +287,79 @@ def test_login_signup_full_width_mobil(client):
# Regula mobil: cardul nu depaseste viewport-ul.
mobil = html[html.find("@media (max-width:767px)"):]
assert ".auth-card" in mobil, ruta
assert "max-width:100%" in mobil, ruta
# ============================================================
# PRD 5.12 US-008: responsive tableta+mobil + header fara suprapuneri
# ============================================================
def test_header_are_breakpoint_tableta(client):
"""US-008 RED: exista reguli @media intre 768 si 1024 pentru header.
Desktop: grid 3-coloane + min-height:92px. Pe tableta (768-1024px) nu exista
inca breakpoint — logo+titlu+badge+tema+versiune+hamburger se inghesuie si se suprapun."""
_create_account_user("bt@test.com")
_login(client, "bt@test.com")
html = client.get("/?tab=acasa").text
# Bloc media dedicat tabletei (range min-width:768 si max-width:1024px).
assert "@media (min-width:768px) and (max-width:1024px)" in html, \
"Lipseste blocul @media tableta (min-width:768px) and (max-width:1024px)"
# Blocul tableta contine reguli pentru header sau elemente de header.
idx = html.find("@media (min-width:768px) and (max-width:1024px)")
bloc = html[idx:idx + 800]
assert "header" in bloc or ".brand-logo" in bloc, \
"Blocul tableta nu are reguli pentru header sau .brand-logo"
def test_header_elemente_nu_au_min_height_fix_pe_mobil(client):
"""US-008 RED: header-ul nu forteaza min-height:92px pe tableta si mobil.
Regula de baza (desktop) are min-height:92px; pe tableta (768-1024px)
lipseste resetarea -> inghesuire garantata la ~820px."""
_create_account_user("mh@test.com")
_login(client, "mh@test.com")
html = client.get("/?tab=acasa").text
# Regula de baza desktop are min-height:92px (sa nu dispara).
assert "min-height:92px" in html, "Regula desktop min-height:92px a disparut"
# Blocul tableta (768-1024px) trebuie sa reseteze min-height pe header.
idx_t = html.find("@media (min-width:768px) and (max-width:1024px)")
assert idx_t != -1, "Lipseste blocul @media tableta (768-1024px)"
tableta = html[idx_t:idx_t + 800]
assert "min-height:0" in tableta, \
"Blocul tableta nu reseteaza min-height pentru header"
# Blocul mobil (<768px) reseteaza si el min-height (regresie: nu a disparut).
# Folosim `{` ca sa nu potrivim mentionarile din comentarii CSS.
mobil_idx = html.find("@media (max-width:767px) {")
assert mobil_idx != -1
mobil = html[mobil_idx:mobil_idx + 5000]
assert "min-height:0" in mobil, "Blocul mobil a pierdut resetarea min-height pe header"
def test_modal_full_screen_pe_mobil(client):
"""US-008 D#13 verificare: regula full-screen mobil pentru modal exista in base.html
(@media max-width:767px) si se aplica modalului global prin clasa modal-overlay.
VERIFICA prezenta regulii, NU re-implementa."""
_create_account_user("mfp@test.com")
_login(client, "mfp@test.com")
html = client.get("/?tab=acasa").text
# Regula CSS full-screen exista in blocul @media (max-width:767px) {.
# Folosim varianta cu `{` ca sa NU potrivim mentionarile din comentarii CSS.
mobil_idx = html.find("@media (max-width:767px) {")
assert mobil_idx != -1, "Nu exista bloc @media (max-width:767px) { in CSS"
mobil = html[mobil_idx:mobil_idx + 5000]
assert "100vw" in mobil, "Dialogul nu are latime 100vw pe mobil"
assert "100vh" in mobil, "Dialogul nu are inaltime 100vh pe mobil"
# Butonul de inchidere >=44px (tinta touch) pe mobil.
assert "44px" in mobil, "Butonul modal-close nu are tinta touch 44px pe mobil"
# Modalul global din HTML foloseste clasa modal-overlay -> prinde regula CSS.
assert 'class="modal-overlay"' in html, \
"Modalul global nu are class=modal-overlay (nu prinde regula full-screen)"
# Target swap pentru editare preview (US-006) exista in DOM.
assert 'id="detaliu-modal-body"' in html, \
"Target #detaliu-modal-body lipseste din base.html"

View File

@@ -79,10 +79,12 @@ def test_signup_creeaza_cont_user_si_cheie(client):
def test_signup_email_duplicat_eroare(client):
"""Email duplicat -> ROLLBACK; COUNT(accounts) neschimbat (fara cont orfan)."""
from tests.conftest import make_test_cui
resp = client.get("/signup")
token = _csrf(resp.text)
client.post("/signup", data={
"name": "Service A",
"cui": make_test_cui("dup@example.com"),
"email": "dup@example.com",
"parola": "parolasecreta",
"csrf_token": token,
@@ -97,6 +99,7 @@ def test_signup_email_duplicat_eroare(client):
token = _csrf(resp.text)
resp2 = client.post("/signup", data={
"name": "Service B",
"cui": make_test_cui("dup-b@example.com"),
"email": "dup@example.com",
"parola": "altaparola123",
"csrf_token": token,
@@ -138,11 +141,13 @@ def test_signup_parola_scurta_eroare(client):
def test_cheie_afisata_o_data(client):
"""Cheia rfak_ apare in raspunsul POST /signup; GET /signup nu o contine."""
from tests.conftest import make_test_cui
resp = client.get("/signup")
token = _csrf(resp.text)
resp_post = client.post("/signup", data={
"name": "Service Cheie",
"cui": make_test_cui("cheie@test.com"),
"email": "cheie@test.com",
"parola": "parolasecreta",
"csrf_token": token,