5.15 (propagare design + dashboard editare) si 5.14 (mapare LLM distilata) inchise dupa /code-review high. 8 buguri reparate TDD: - HIGH modal nu se deschidea pe randul slim (base.html: trimitere-slim) - HIGH /repune trunchia prestatii (declaratie incompleta la RAR) -> iterare peste existing, codes pozitional - HIGH embeddings incarca model ~230MB degeaba pe corpus gol -> poarta has_corpus() - HIGH picker chips gol pe re-render eroare -> conn/account_id pe toate ramurile - MED obs re-derivat dupa stergere explicita -> _merge_override pastreaza obs='' - MED mapare salvata fara denumire poluă GOLD -> _record_gold_validation guard - MED typo nome_prestatie -> nume_prestatie in select /repune - MED bucketare timp +3h gresita iarna -> SQLite localtime + TZ=Europe/Bucharest Embeddings WIRE-uit functional (PRD #15, decizie user): ensure_embeddings_corpus construieste corpus din nomenclator, gated pe AUTOPASS_EMBEDDINGS_ENABLED (default off). Marime model corectata ~50MB->~230MB (estimare PRD gresita). Cleanup: hoist load_* din bucla bulk-fix; import re la top. Regresie: 1256 passed, 1 deselected (live), 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
473 lines
16 KiB
Python
473 lines
16 KiB
Python
"""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}"
|
|
)
|