Files
rar-autopass/tests/test_import_edit_row.py
Claude Agent 6f6b163867 feat(web): editare celule in preview + Acasa unificata (PRD 3.6)
Implementeaza PRD 3.6 (US-001..007), pe canalul de import + stratul web;
worker / masina stari / idempotenta / mapare raman neatinse.

- US-003/004: tab-ul "Trimiteri" eliminat; Trimiterile devin sectiune
  permanenta sub upload pe Acasa ("Trimiterile tale"); upload comprimat la
  bara slim (hero pastrat la first-run); ?tab=coada si /_fragments/coada
  servesc Acasa (fara fragment orfan); poll gated pe visibilityState.
- US-001: coloana noua import_rows.override_json (nullable, Fernet, Approach B)
  + _migrate defensiv; ruta v1 + alias web .../rand/{i}/editeaza aplica patch
  canonic ULTIMUL in _resolve_row_for_preview si commit_import (mutatie pura,
  status rederivat, fara drift). Scoping JOIN -> 404, guard committed -> 409,
  semantica empty=clear, decrypt fail -> no-op.
- US-002: buton "Editeaza" pe rand; swap pe <tr> + OOB contoare (nu pe sectiune);
  form propriu (confirm dezactivat la editare); refoloseste grila responsiva +
  error-map din _trimitere_detaliu.html; mutual-exclusion intre randuri.
- US-005/006: "De rezolvat", "Operatii salvate" si "Formate de coloane" ca
  tabele (.tablewrap); H4: comutatorul reflecta auto_send STOCAT.
- US-007: bifa "auto-send" devine comutator etichetat pe COADA ("Pune automat
  in coada" / "Tine pentru verificare"), scoped pe operatie; name="auto_send"
  pastrat (semantica de prezenta -> bool corect cu ambele parsere, zero backend).

Fix-uri gasite la verificarea E2E in browser (htmx 1.9.12, JS — invizibile la
TestClient): useTemplateFragments=true (raspuns <tr>+OOB era parsat in context
de tabel -> swapError + contoare pierdute); re-activarea confirm-btn dupa salvare
deferita pe tick (evita editing=true tranzitoriu); n-hint actualizat de updateN.

Teste: 523 passed. E2E browser: Acasa unificata, upload slim, editare rand
(needs_data -> ok, swap pe rand, contoare OOB), Mapari tabelar + comutator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:52:17 +00:00

276 lines
9.7 KiB
Python

"""Teste US-001 (PRD 3.6): backend — persista editarea unui rand de preview.
Approach B: editarea scrie un patch CANONIC in import_rows.override_json (criptat
Fernet), aplicat ULTIMUL in _resolve_row_for_preview + commit. raw_json/idempotency
raman neatinse. Ruta editare = mutatie PURA (nu re-deriva status, nu atinge submissions).
TDD: testele se scriu inainte de implementare (RED -> GREEN).
"""
from __future__ import annotations
import io
import os
import pathlib
import tempfile
import pytest
from fastapi.testclient import TestClient
_FIXTURES = pathlib.Path(__file__).parent / "fixtures"
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "edit.db"))
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:
"""Nomenclator + mapare OP-1 -> R-FRANE (auto_send=1) pentru contul dat."""
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 _upload(client: TestClient, fixture: str, headers: dict | None = None) -> int:
data = (_FIXTURES / fixture).read_bytes()
r = client.post(
"/v1/import",
files={"file": (fixture, io.BytesIO(data), "text/csv")},
headers=headers or {},
)
assert r.status_code == 200, r.text
return int(r.json()["import_id"])
def _save_mapping(client: TestClient, import_id: int, json_mapare: dict,
headers: dict | None = None) -> None:
r = client.post(
f"/v1/import/{import_id}/column-mapping",
json={"json_mapare": json_mapare, "format_data": "YYYY-MM-DD"},
headers=headers or {},
)
assert r.status_code == 200, r.text
def _preview(client: TestClient, import_id: int, headers: dict | None = None) -> list[dict]:
r = client.get(f"/v1/import/{import_id}/preview", headers=headers or {})
assert r.status_code == 200, r.text
return r.json()["rows"]
def _status_for(rows: list[dict], row_index: int) -> str:
return next(r["resolved_status"] for r in rows if r["row_index"] == row_index)
def _resolved_for(rows: list[dict], row_index: int) -> dict:
return next(r["resolved"] for r in rows if r["row_index"] == row_index)
_MAP_NECANONIC = {
"Serie sasiu": "vin",
"Nr": "nr_inmatriculare",
"Data": "data_prestatie",
"KM": "odometru_final",
"Operatie": "operatie",
}
_MAP_LIPSA = {
"Serie sasiu": "vin",
"Nr": "nr_inmatriculare",
"KM": "odometru_final",
"Operatie": "operatie",
}
# --------------------------------------------------------------------------- #
# Teste #
# --------------------------------------------------------------------------- #
def test_editeaza_rand_antet_necanonic_devine_ok(client):
"""Fisier cu antet ne-canonic (Serie sasiu/Data): rand needs_data -> editez data -> ok.
Prinde bug-ul de stocare: o editare pe cheia canonica trebuie sa se aplice chiar
daca raw_json e cheiat pe anteturi ne-canonice.
"""
_seed_op1()
iid = _upload(client, "import_antet_necanonic.csv")
_save_mapping(client, iid, _MAP_NECANONIC)
assert _status_for(_preview(client, iid), 0) == "needs_data"
r = client.post(f"/v1/import/{iid}/rand/0/editeaza", json={"data_prestatie": "2026-06-10"})
assert r.status_code == 200, r.text
assert _status_for(_preview(client, iid), 0) == "ok"
def test_editeaza_completeaza_coloana_absenta(client):
"""Fisier FARA coloana de data: editarea adauga data_prestatie -> ok.
Demonstreaza ca override poate exprima un camp a carui coloana LIPSESTE din fisier.
"""
_seed_op1()
iid = _upload(client, "import_lipsa_coloana.csv")
_save_mapping(client, iid, _MAP_LIPSA)
assert _status_for(_preview(client, iid), 0) == "needs_data"
r = client.post(f"/v1/import/{iid}/rand/0/editeaza", json={"data_prestatie": "2026-06-10"})
assert r.status_code == 200, r.text
assert _status_for(_preview(client, iid), 0) == "ok"
def test_editeaza_status_identic_cu_GET_preview(client):
"""Ruta editare NU re-deriva status (intoarce doar override); statusul vine din GET preview."""
_seed_op1()
iid = _upload(client, "import_antet_necanonic.csv")
_save_mapping(client, iid, _MAP_NECANONIC)
r = client.post(f"/v1/import/{iid}/rand/0/editeaza", json={"data_prestatie": "2026-06-10"})
assert r.status_code == 200
body = r.json()
# Mutatie pura: raspunsul nu contine un status rederivat in ruta.
assert "resolved_status" not in body
assert body.get("override", {}).get("data_prestatie") == "2026-06-10"
# Statusul se rederiva DOAR prin preview.
assert _status_for(_preview(client, iid), 0) == "ok"
def test_editeaza_rand_scoped_alt_cont_404(client):
"""Editarea unui rand al altui cont -> 404 (scoping JOIN, fara leak)."""
from app.db import get_connection
from app.accounts import create_account
from app.auth import create_api_key
_seed_op1()
iid = _upload(client, "import_antet_necanonic.csv") # cont 1 (default)
_save_mapping(client, iid, _MAP_NECANONIC)
conn = get_connection()
try:
acct2 = create_account(conn, "Alt cont", active=True)
key2 = create_api_key(conn, acct2)
conn.commit()
finally:
conn.close()
r = client.post(
f"/v1/import/{iid}/rand/0/editeaza",
json={"data_prestatie": "2026-06-10"},
headers={"X-API-Key": key2},
)
assert r.status_code == 404
def test_editeaza_batch_inexistent_404(client):
r = client.post("/v1/import/99999/rand/0/editeaza", json={"vin": "WVWZZZ1KZAW000123"})
assert r.status_code == 404
def test_editeaza_row_index_invalid_pe_batch_valid_404(client):
_seed_op1()
iid = _upload(client, "import_antet_necanonic.csv")
_save_mapping(client, iid, _MAP_NECANONIC)
r = client.post(f"/v1/import/{iid}/rand/9999/editeaza", json={"vin": "WVWZZZ1KZAW000123"})
assert r.status_code == 404
def test_editeaza_pastreaza_campuri_neatinse(client):
"""Editarea unui camp nu pierde prestatiile/operatia rezolvate ale randului."""
_seed_op1()
iid = _upload(client, "import_antet_necanonic.csv")
_save_mapping(client, iid, _MAP_NECANONIC)
client.post(f"/v1/import/{iid}/rand/0/editeaza", json={"data_prestatie": "2026-06-10"})
res = _resolved_for(_preview(client, iid), 0)
prestatii = res.get("prestatii") or []
assert prestatii, "prestatiile au disparut dupa editare"
assert (prestatii[0].get("cod_prestatie") or prestatii[0].get("cod_op_service")) in ("R-FRANE", "OP-1")
def test_editeaza_batch_committed_409(client):
"""Editarea pe un batch deja comis -> 409 (nu mai are efect downstream)."""
from app.db import get_connection
_seed_op1()
iid = _upload(client, "import_antet_necanonic.csv")
_save_mapping(client, iid, _MAP_NECANONIC)
conn = get_connection()
try:
conn.execute("UPDATE import_batches SET status='committed' WHERE id=?", (iid,))
conn.commit()
finally:
conn.close()
r = client.post(f"/v1/import/{iid}/rand/0/editeaza", json={"data_prestatie": "2026-06-10"})
assert r.status_code == 409
def test_editeaza_raw_corupt_no_op(client):
"""Override curent ilizibil (decrypt fail) -> 422/no-op, fara scriere goala, fara crash."""
from app.db import get_connection
_seed_op1()
iid = _upload(client, "import_antet_necanonic.csv")
_save_mapping(client, iid, _MAP_NECANONIC)
conn = get_connection()
try:
conn.execute(
"UPDATE import_rows SET override_json=? WHERE batch_id=? AND row_index=0",
("token-corupt-nedecriptabil", iid),
)
conn.commit()
finally:
conn.close()
r = client.post(f"/v1/import/{iid}/rand/0/editeaza", json={"data_prestatie": "2026-06-10"})
assert r.status_code == 422
# override_json NU a fost suprascris cu o valoare goala
conn = get_connection()
try:
oj = conn.execute(
"SELECT override_json FROM import_rows WHERE batch_id=? AND row_index=0", (iid,)
).fetchone()["override_json"]
finally:
conn.close()
assert oj == "token-corupt-nedecriptabil"
def test_editeaza_empty_input_sterge_campul(client):
"""Semantica empty: input gol = STERGE cheia din override (revine la valoarea din fisier)."""
_seed_op1()
iid = _upload(client, "import_antet_necanonic.csv")
_save_mapping(client, iid, _MAP_NECANONIC)
# Randul 1 are odometru_final 55000 in fisier. Suprascriem cu 77000 prin override.
client.post(f"/v1/import/{iid}/rand/1/editeaza", json={"odometru_final": "77000"})
assert _resolved_for(_preview(client, iid), 1).get("odometru_final") == "77000"
# Input gol -> sterge override-ul -> revine la valoarea din fisier (55000).
client.post(f"/v1/import/{iid}/rand/1/editeaza", json={"odometru_final": ""})
assert _resolved_for(_preview(client, iid), 1).get("odometru_final") == "55000"