Dogfood pe import + Trimiteri (mobil/tableta <1024px), pur CSS + markup, backend
trimitere neatins:
- Card compact real pentru .tabel-trimiteri (preview + Trimiteri): vehicul=titlu,
stare=pill dreapta-sus, operatie+cod, meta data/km muted, nota mica. Inlocuieste
stiva generica eticheta+valoare (carduri de ~450px -> ~135px). Anuleaza regula
desktop tr.trimitere-row > td{padding:11px} in blocul compact.
- FIX editare preview: OOB swap pe <tr> esua tacit in htmx 1.9 (un <tr> brut se
pierde la parsarea unui fragment fara context de tabel) -> randul ramanea cu
starea veche dupa salvare. Inlocuit cu reload complet al preview-ului prin
HX-Trigger:reincarcaPreview + detalii randSalvat. /editeaza si /confirma-review
folosesc helper-ul _raspuns_rand_salvat.
- Feedback post-salvare: toast global "Randul N actualizat · <stare>" + scroll +
flash pe randul actualizat (base.html window.arataToast + listener randSalvat).
- Modal editare: Salveaza + Anuleaza pe acelasi rand (sistem .act): desktop text,
mobil doua iconite Lucide 44px alaturate (save/x). Macro icon('x') + .act-primary.
- Randuri deja-trimise/duplicate colapsate implicit in preview + toggle "Arata N".
- Select "Operatii de mapat" full-width pe mobil (nu mai iese din viewport).
- Bara de filtre Trimiteri adaptata mobil: pills pe banda cu scroll orizontal,
cautare vehicul proeminenta (nu 8 butoane full-width stivuite).
- Nota preview = culoarea camp-fix (accent) ca sa atraga atentia; hint-urile
camp-fix per-camp scoase (campul Note e self-explanatory).
- Confirmare trimitere: scos campul email (Declarant); text mai clar
("Confirma numarul din N gata de trimis"). Backend confirmed_by ramane optional.
Teste: contractul OOB (rupt in browser) inlocuit cu noul contract
(reincarcaPreview + randSalvat) in test_web_preview_edit / test_preview_edit_ui /
test_import_review. Suita: 992 passed (exclus live).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
609 lines
23 KiB
Python
609 lines
23 KiB
Python
"""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
|
|
- HX-Trigger: randSalvat cu noua stare 'Gata de trimis' (pentru toast)
|
|
- HX-Trigger: reincarcaPreview + HX-Trigger-After-Settle: inchideModal
|
|
"""
|
|
import json as _json
|
|
_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"
|
|
|
|
# Contractul nou: reload preview + randSalvat cu noua stare (nu OOB pe <tr>).
|
|
trig = _json.loads(r.headers.get("HX-Trigger", "{}"))
|
|
assert trig.get("reincarcaPreview") is True, "confirma-review trebuie sa ceara reincarcaPreview"
|
|
assert trig.get("randSalvat", {}).get("stare") == "Gata de trimis", \
|
|
"Dupa confirmare, randSalvat.stare trebuie sa fie 'Gata de trimis' (pentru toast)"
|
|
|
|
# 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_cere_reincarcarea_preview(client):
|
|
"""Contractul nou (dogfood 5.13): confirma-review NU mai depinde de scriptul updateN
|
|
din payload (care, cu OOB pe <tr> rupt, lasa randul stale). Acum cere reincarcaPreview,
|
|
iar preview-ul reincarcat re-randeaza contorul si butonul de confirmare cu n_confirmat
|
|
corect server-side — deci problema B1 (n_confirmat stale -> 422) dispare structural.
|
|
|
|
Verifica:
|
|
- Raspuns 200
|
|
- HX-Trigger contine reincarcaPreview (reincarca contorul/confirmarea, fresh)
|
|
"""
|
|
import json as _json
|
|
_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
|
|
|
|
trig = _json.loads(r.headers.get("HX-Trigger", "{}"))
|
|
assert trig.get("reincarcaPreview") is True, (
|
|
"confirma-review trebuie sa ceara reincarcaPreview — preview-ul reincarcat aduce "
|
|
"n_confirmat corect server-side (fara dependenta de scriptul updateN din payload)."
|
|
)
|