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>
276 lines
9.7 KiB
Python
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"
|