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:
@@ -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'`."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
618
tests/test_import_review.py
Normal 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
203
tests/test_signup.py
Normal 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"])
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
174
tests/test_web_form_editare.py
Normal file
174
tests/test_web_form_editare.py
Normal 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
421
tests/test_web_mapare_op.py
Normal 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()
|
||||
165
tests/test_web_mapcoloane.py
Normal file
165
tests/test_web_mapcoloane.py
Normal 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"
|
||||
182
tests/test_web_preview_compact.py
Normal file
182
tests/test_web_preview_compact.py
Normal 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"
|
||||
541
tests/test_web_preview_edit.py
Normal file
541
tests/test_web_preview_edit.py
Normal 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)."
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user