"""Teste US-005 (PRD 5.15): obs editabil + concat operatie la import. AC-uri: - obs adaugat in bucla de campuri din post_corecteaza (routes.py) si in EDIT_FIELDS (import_router.py); corecteaza si editeaza preview accepta si persista obs. - obs optional (text liber, fara validare de continut, doar .strip()). - obs apare in prezentare_din_payload (payload_view.py). - obs EXCLUS din cheia de idempotenta (D8): editarea obs NU schimba cheia. - La import fara coloana obs: denumirea operatiei se COPIAZA in obs (D7). - Derive-on-empty idempotent: re-preview NU dubleaza obs (E3). TDD: toate testele se scriu INAINTE de implementare (RED -> GREEN). """ from __future__ import annotations import io import json import os import re import tempfile import pytest from starlette.testclient import TestClient # --------------------------------------------------------------------------- # # Fixtures # # --------------------------------------------------------------------------- # @pytest.fixture() def client(monkeypatch): """Client web cu autentificare activa (pentru corecteaza).""" tmp = tempfile.mkdtemp() monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "obs.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() @pytest.fixture() def api_client(monkeypatch): """Client API fara autentificare web (pentru import preview + editeaza).""" tmp = tempfile.mkdtemp() monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "obs_api.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 _create_account_user(email: str, 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, "Service", 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, "csrf_token nu a fost gasit in pagina de 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 _csrf(client) -> str: resp = client.get("/?tab=coada") m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text) assert m, "csrf_token nu a fost gasit in dashboard" return m.group(1) def _insert(acct: int, *, status: str, payload: dict, key: str | None = None) -> int: from app.db import get_connection conn = get_connection() try: k = key or f"k-{os.urandom(6).hex()}" cur = conn.execute( "INSERT INTO submissions (idempotency_key, account_id, status, payload_json) " "VALUES (?, ?, ?, ?)", (k, acct, status, json.dumps(payload)), ) conn.commit() return int(cur.lastrowid) finally: conn.close() def _row_payload(sid: int) -> dict: from app.db import get_connection conn = get_connection() try: r = conn.execute("SELECT payload_json FROM submissions WHERE id=?", (sid,)).fetchone() return json.loads(r["payload_json"]) finally: conn.close() def _row_status(sid: int) -> str: from app.db import get_connection conn = get_connection() try: r = conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone() return r["status"] finally: conn.close() def _seed_nomenclator(cod: str = "OE-1", op_service: str = "Schimb ulei") -> None: """Insereaza cod in nomenclator si mapare op_service -> cod pentru contul 1.""" from app.db import get_connection conn = get_connection() try: conn.execute( "INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)", (cod, "Schimb ulei motor"), ) conn.execute( "INSERT OR IGNORE INTO operations_mapping " "(account_id, cod_op_service, cod_prestatie, auto_send) " "VALUES (1, ?, ?, 1)", (op_service, cod), ) 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") def _upload(client, data: bytes, filename: str = "test.csv") -> int: r = client.post( "/v1/import", files={"file": (filename, io.BytesIO(data), "text/csv")}, ) assert r.status_code == 200, r.text return int(r.json()["import_id"]) def _save_mapping(client, import_id: int, json_mapare: dict) -> None: r = client.post( f"/v1/import/{import_id}/column-mapping", json={"json_mapare": json_mapare, "format_data": "YYYY-MM-DD"}, ) assert r.status_code == 200, r.text def _preview(client, import_id: int) -> list[dict]: r = client.get(f"/v1/import/{import_id}/preview") assert r.status_code == 200, r.text return r.json()["rows"] # --------------------------------------------------------------------------- # # Teste # # --------------------------------------------------------------------------- # def test_obs_editabil_persistat_corecteaza(client): """AC: obs adaugat in bucla post_corecteaza -> persists in payload_json. RED: 'obs' nu e inca in bucla de campuri din post_corecteaza (routes.py:1177). """ acct = _create_account_user("obs.corecteaza@test.com") _login(client, "obs.corecteaza@test.com") # Submission needs_data cu odometru gol (trigger pentru blocaj) sid = _insert(acct, status="needs_data", payload={ "vin": "WVWZZZ1JZXW0AB001", "nr_inmatriculare": "B100AAA", "data_prestatie": "2026-06-10", "odometru_final": "", # trigger needs_data "prestatii": [{"cod_prestatie": "OE-1"}], }) csrf = _csrf(client) resp = client.post( f"/trimitere/{sid}/corecteaza", data={ "csrf_token": csrf, "odometru_final": "50000", # fix odo "obs": "Schimb ulei verificat", # obs editabil }, ) assert resp.status_code == 200, ( f"Status neasteptat: {resp.status_code}\n{resp.text[:500]}" ) payload = _row_payload(sid) assert payload.get("obs") == "Schimb ulei verificat", ( f"obs nu e persistat in payload_json; payload={payload}" ) assert _row_status(sid) == "queued", ( f"status neasteptat: {_row_status(sid)}" ) def test_obs_persistat_preview_editeaza(api_client): """AC: obs in EDIT_FIELDS + RandEditIn -> editeaza preview salveaza obs -> apare in resolved. RED: 'obs' nu e in RandEditIn (import_router.py:1188) sau in EDIT_FIELDS (:261). """ _seed_nomenclator() data = _csv_bytes([{ "VIN": "WVWZZZ1JZXW0AB002", "Nr": "B200BBB", "Data": "2026-06-10", "KM": "50000", "Operatie": "Schimb ulei", # Fara coloana Observatii: obs vine din derive }]) iid = _upload(api_client, data) _save_mapping(api_client, iid, { "VIN": "vin", "Nr": "nr_inmatriculare", "Data": "data_prestatie", "KM": "odometru_final", "Operatie": "operatie", }) rows = _preview(api_client, iid) assert rows[0]["resolved_status"] == "ok", f"Stare neasteptata inainte de edit: {rows[0]}" # Editeaza obs explicit pe randul 0 r = api_client.post( f"/v1/import/{iid}/rand/0/editeaza", json={"obs": "Observatie test manuala"}, ) assert r.status_code == 200, r.text body = r.json() assert body.get("override", {}).get("obs") == "Observatie test manuala", ( f"obs nu e in override returnat: {body}" ) # Preview dupa editare: obs din override trebuie sa apara in resolved rows2 = _preview(api_client, iid) resolved_obs = rows2[0]["resolved"].get("obs") assert resolved_obs == "Observatie test manuala", ( f"obs nu apare in resolved dupa editeaza; resolved={rows2[0]['resolved']}" ) def test_obs_optional_gol_ok(client): """AC: obs optional; o trimitere fara obs trece validarea si devine queued. RED: implicit nu esueaza, dar ne asiguram ca lipsa obs nu introduce o eroare. """ acct = _create_account_user("obs.gol@test.com") _login(client, "obs.gol@test.com") sid = _insert(acct, status="needs_data", payload={ "vin": "WVWZZZ1JZXW0AB006", "nr_inmatriculare": "B600FFF", "data_prestatie": "2026-06-10", "odometru_final": "", # trigger needs_data "prestatii": [{"cod_prestatie": "OE-1"}], }) csrf = _csrf(client) # Corecteaza FARA obs in form (obs absent) resp = client.post( f"/trimitere/{sid}/corecteaza", data={ "csrf_token": csrf, "odometru_final": "50000", # obs absent din form }, ) assert resp.status_code == 200, resp.text assert _row_status(sid) == "queued", ( f"Status neasteptat dupa corectie fara obs: {_row_status(sid)}" ) def test_import_concateneaza_operatie_in_obs(api_client): """AC (D7): import fara coloana obs -> obs = denumire operatie in preview. RED: obs nu e derivat din operatie la import (inca nu e implementat in _resolve_row_for_preview). """ _seed_nomenclator(cod="OE-1", op_service="Schimb ulei") data = _csv_bytes([{ "VIN": "WVWZZZ1JZXW0AB003", "Nr": "B300CCC", "Data": "2026-06-10", "KM": "50000", "Operatie": "Schimb ulei", # Fara coloana Observatii in fisier }]) iid = _upload(api_client, data) _save_mapping(api_client, iid, { "VIN": "vin", "Nr": "nr_inmatriculare", "Data": "data_prestatie", "KM": "odometru_final", "Operatie": "operatie", # "Observatii" nu e in mapare -> obs vine din derive }) rows = _preview(api_client, iid) resolved = rows[0]["resolved"] obs = resolved.get("obs", "") assert obs == "Schimb ulei", ( f"obs trebuie sa fie 'Schimb ulei' (copiat din operatie); got={obs!r}" ) def test_anti_dublu_concat(api_client): """AC (E3): DERIVE-ON-EMPTY idempotent; re-preview si override explicit NU dubleaza obs. RED: fara DERIVE-ON-EMPTY, un al doilea preview sau o editare cu obs setat ar putea produce 'Schimb ulei; Schimb ulei'. """ _seed_nomenclator(cod="OE-1", op_service="Schimb ulei") data = _csv_bytes([{ "VIN": "WVWZZZ1JZXW0AB004", "Nr": "B400DDD", "Data": "2026-06-10", "KM": "50000", "Operatie": "Schimb ulei", }]) iid = _upload(api_client, data) _save_mapping(api_client, iid, { "VIN": "vin", "Nr": "nr_inmatriculare", "Data": "data_prestatie", "KM": "odometru_final", "Operatie": "operatie", }) # Primul preview: obs derivat din operatie rows1 = _preview(api_client, iid) obs1 = rows1[0]["resolved"].get("obs", "") assert obs1 == "Schimb ulei", f"Primul preview: obs neasteptat: {obs1!r}" # Simulam utilizatorul care seteaza explicit obs = valoarea deja derivata r = api_client.post( f"/v1/import/{iid}/rand/0/editeaza", json={"obs": "Schimb ulei"}, ) assert r.status_code == 200, r.text # Al doilea preview: obs NU trebuie dublat rows2 = _preview(api_client, iid) obs2 = rows2[0]["resolved"].get("obs", "") assert obs2 == "Schimb ulei", ( f"Al doilea preview a produs obs gresit: {obs2!r} (asteptat: 'Schimb ulei')" ) assert "Schimb ulei; Schimb ulei" not in obs2, ( f"obs a fost dublat: {obs2!r}" ) # Al treilea preview (fara nicio alta editare): inca nu se dubleaza rows3 = _preview(api_client, iid) obs3 = rows3[0]["resolved"].get("obs", "") assert obs3 == "Schimb ulei", ( f"Al treilea preview a produs obs gresit: {obs3!r}" ) def test_obs_sters_explicit_nu_se_re_deriveaza(api_client): """Bug fix (code-review 5.15): obs='' (sters explicit de user) NU se re-deriveaza. obs e camp derivat (copiaza denumirea operatiei cand e gol). Cand userul sterge obs in preview (obs=''), _merge_override pastreaza acum obs='' in override (nu il mai face pop) -> override aplicat ultimul suprascrie derive-on-empty -> obs ramane gol. Inainte: pop -> obs gol -> re-derivat din denumire -> stergerea ignorata. RED inainte de fix: al doilea preview re-deriveaza obs = 'Schimb ulei'. """ _seed_nomenclator(cod="OE-1", op_service="Schimb ulei") data = _csv_bytes([{ "VIN": "WVWZZZ1JZXW0AB009", "Nr": "B900GGG", "Data": "2026-06-10", "KM": "50000", "Operatie": "Schimb ulei", }]) iid = _upload(api_client, data) _save_mapping(api_client, iid, { "VIN": "vin", "Nr": "nr_inmatriculare", "Data": "data_prestatie", "KM": "odometru_final", "Operatie": "operatie", }) # Primul preview: obs derivat din operatie. rows1 = _preview(api_client, iid) assert rows1[0]["resolved"].get("obs") == "Schimb ulei" # Userul STERGE obs (string gol). r = api_client.post( f"/v1/import/{iid}/rand/0/editeaza", json={"obs": ""}, ) assert r.status_code == 200, r.text # Preview dupa stergere: obs trebuie sa RAMANA gol (NU re-derivat). rows2 = _preview(api_client, iid) obs2 = rows2[0]["resolved"].get("obs", "") assert obs2 == "", ( f"obs sters explicit a fost re-derivat: {obs2!r} (asteptat gol)" ) # Idempotent: al treilea preview tot gol. rows3 = _preview(api_client, iid) assert rows3[0]["resolved"].get("obs", "") == "", ( f"obs sters re-derivat la al treilea preview: {rows3[0]['resolved'].get('obs')!r}" ) def test_obs_nu_schimba_cheia_idempotenta(): """AC (D8): editarea obs NU schimba cheia de idempotenta. Fara import circular DB; testeaza direct functiile din idempotency.py. RED: daca obs ar fi in build_key, doua versiuni (cu/fara obs) ar produce chei diferite. """ from app.idempotency import build_key, canonicalize_row payload_fara_obs = { "vin": "WVWZZZ1JZXW0AB005", "nr_inmatriculare": "B500EEE", "data_prestatie": "2026-06-10", "odometru_final": "50000", "prestatii": [{"cod_prestatie": "OE-1"}], } payload_cu_obs = { **payload_fara_obs, "obs": "Schimb ulei motor 5W30 adaugat dupa", } canon1 = canonicalize_row(payload_fara_obs) canon2 = canonicalize_row(payload_cu_obs) key1 = build_key(1, canon1) key2 = build_key(1, canon2) assert key1 == key2, ( f"obs a schimbat neasteptat cheia de idempotenta!\n" f" fara obs: {key1}\n" f" cu obs: {key2}" )