PRD 5.16 — propagare design finalizata (system font stack, fara IBM Plex self-hostat): - US-001/002/008: tokeni --font-ui/--font-mono (system stack) + scala --fs-*; zero @font-face si zero /static/fonts/; landing aliniat la acelasi stack - US-003: RAR online = dot compact in antet + meniu burger; banda rosie DOAR pe blocat (invariant zero-silent-failures pastrat) - US-010: antet "ROMFAST AUTOPASS" + nume service + /login brandeit 2 coloane + badge plan; meniu burger cu separatoare; gate strict pe is_authenticated - US-011: selector tema pill icon+eticheta (reuse THEMES) - US-004/005/006/007: bug-fix editor prestatii (picker cod+denumire, add_extra in mod operatii, cod ales se salveaza fara "+", Renunta inchide via closest) - US-012/013: landing Autentificare->/login; wizard import colapsat + 4 pasi pe tokeni - fix VERIFY E2E: contoare duplicate pe 390px (inline display:flex batea @media) -> CSS + test-lock PRD 5.17 — tipuri de cont + trial Pro 30z + enforcement DUR: - US-001/002/008: accounts.tier + trial_until (migrare aditiva defensiva); app/plans.py sursa unica (PLANS, FREE_MONTHLY_LIMIT=60, effective_tier(now injectabil), monthly_usage, CONSUMED_STATUSES); create_account trial Pro 30z; CLI set-tier (protejat id=1, audit) - US-003/004/005: enforce volum 60/luna INAINTE de build_key pe ambele canale (PLAN_LIMITA_LUNARA, 3 niveluri + log_event); gate API Pro+ (PLAN_FARA_API 403 actionabil); valideaza/nomenclator raman permise; downgrade lazy; flag AUTOPASS_ENFORCE_PLANS (kill-switch) - US-006: badge plan antet + linie burger + consum N/60 + warn>=80% + 6 stari + copy RO pluralizat + banner one-time trial->Gratuit + pagina Cont Regresie: 1380 passed, 0 failed, 1 deselected (live). E2E browser pe 390/1280 confirmat. Backend trimitere (worker/masina stari/idempotenta/contract RAR) NEATINS. Lucrul 5.18 (corpus kNN) ramane separat, necomis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
860 lines
32 KiB
Python
860 lines
32 KiB
Python
"""Teste US-006 (PRD 5.15): prestatii multi-cod (lista) la editare/corectie.
|
|
|
|
AC-uri verificate:
|
|
- Handler-ele accepta LISTA de cod_prestatie (form.getlist) -> prestatii cu mai multe coduri.
|
|
- cod_op_service/denumire RAMAN pe item (invariant D7, E1 IRON RULE).
|
|
- Cod invalid -> respins cu mesaj; cod necunoscut NU ajunge la RAR (ORA-12899).
|
|
- Lista goala -> ramane needs_mapping.
|
|
- Dedup per-item: (op_service, cod) unic, NU cod unic (doua ops diferite cu acelasi cod ok).
|
|
- Recalcul idempotenta dupa editare.
|
|
- odometruInitial obligatoriu cand cod_prestatie contine R-ODO/I-ODO.
|
|
- REGRESIE E1 (IRON RULE): op_service supravietuieste /repune cu cod.
|
|
|
|
TDD: toate testele sunt scrise INAINTE de implementare (RED -> GREEN).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import tempfile
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fixtures #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
tmp = tempfile.mkdtemp()
|
|
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "prestatii.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()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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 gasit in login"
|
|
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
|
|
assert resp.status_code == 303
|
|
|
|
|
|
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 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(sid: int):
|
|
from app.db import get_connection
|
|
conn = get_connection()
|
|
try:
|
|
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _payload_json(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 _seed_cod(cod: str, denumire: str = "Prestatie test") -> None:
|
|
"""Insereaza un cod in nomenclator_rar (fara operatii_mapping)."""
|
|
from app.db import get_connection
|
|
conn = get_connection()
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO nomenclator_rar (cod_prestatie, nume_prestatie) VALUES (?, ?)",
|
|
(cod, denumire),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _payload_cu_ops(vin: str, ops: list[tuple[str, str]]) -> dict:
|
|
"""Payload cu prestatii avand cod_op_service/denumire (needs_mapping state)."""
|
|
return {
|
|
"vin": vin,
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [
|
|
{"cod_op_service": op, "denumire": den}
|
|
for op, den in ops
|
|
],
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Teste #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_mai_multe_coduri_acceptate(client):
|
|
"""US-006 AC1: LISTA de cod_prestatie -> prestatii cu N itemi, fiecare cu cod setat.
|
|
|
|
RED: form.get("cod_prestatie") intoarce doar primul cod; form.getlist necesar.
|
|
"""
|
|
acct = _create_account_user("multi.cod@test.com")
|
|
_login(client, "multi.cod@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
_seed_cod("IG-1", "Inlocuire garnitura")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0MC001",
|
|
[("Op-A", "Schimb ulei motor"), ("Op-B", "Inlocuire garnitura chiulasa")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1", "IG-1"], # 2 coduri pentru 2 operatii
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:500]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
assert len(prestatii) == 2, f"asteptat 2 prestatii, got {len(prestatii)}: {prestatii}"
|
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
|
assert "OE-1" in coduri, f"OE-1 lipsa din prestatii: {prestatii}"
|
|
assert "IG-1" in coduri, f"IG-1 lipsa din prestatii: {prestatii}"
|
|
|
|
|
|
def test_cod_op_service_pastrat_dupa_corecteaza(client):
|
|
"""E1/D7: cod_op_service si denumire RAMAN pe item dupa /corecteaza cu cod direct.
|
|
|
|
RED: implementarea veche injecta in prestatii[0] fara sa afecteze op_service
|
|
(intr-adevar in /corecteaza nu se facea pop), dar testul confirma explicit invariantul.
|
|
"""
|
|
acct = _create_account_user("op.pastrat@test.com")
|
|
_login(client, "op.pastrat@test.com")
|
|
_seed_cod("OE-1")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0OP001",
|
|
[("Schimb ulei", "Schimb ulei motor 5W30")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
assert len(prestatii) == 1
|
|
item = prestatii[0]
|
|
assert item.get("cod_prestatie") == "OE-1", f"cod_prestatie lipsa: {item}"
|
|
assert item.get("cod_op_service") == "Schimb ulei", f"cod_op_service pierdut: {item}"
|
|
assert item.get("denumire") == "Schimb ulei motor 5W30", f"denumire pierduta: {item}"
|
|
|
|
|
|
def test_cod_invalid_respins(client):
|
|
"""US-006 AC3: cod necunoscut in nomenclator -> respins cu mesaj, status neschimbat.
|
|
|
|
RED: validarea fata de nomenclator nu e aplicata per-cod la multi-select.
|
|
"""
|
|
acct = _create_account_user("cod.invalid@test.com")
|
|
_login(client, "cod.invalid@test.com")
|
|
# NU seed-uim "XX-99" -> cod necunoscut
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0CI001",
|
|
[("Op-Test", "Operatie test")],
|
|
))
|
|
old_status = _row(sid)["status"]
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "cod_prestatie": "XX-99"},
|
|
)
|
|
assert resp.status_code == 200
|
|
# Cod invalid -> mesaj de eroare vizibil
|
|
assert "XX-99" in resp.text or "necunoscut" in resp.text.lower(), (
|
|
f"Mesaj de eroare lipsa pentru cod invalid; text={resp.text[:500]}"
|
|
)
|
|
# Status neschimbat
|
|
assert _row(sid)["status"] == old_status, (
|
|
f"Status s-a schimbat desi codul e invalid: {_row(sid)['status']}"
|
|
)
|
|
|
|
|
|
def test_lista_goala_needs_mapping(client):
|
|
"""US-006 AC4: nicio cod_prestatie trimis -> submission ramane needs_mapping.
|
|
|
|
RED: cu multi-select, lista goala nu injecteaza nimic; resolve_prestatii
|
|
gaseste inca operatii nemapate -> trebuie sa ramana needs_mapping.
|
|
"""
|
|
acct = _create_account_user("goala.nemap@test.com")
|
|
_login(client, "goala.nemap@test.com")
|
|
# NU seed-uim nicio mapare -> operatia ramane nemapata
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0GN001",
|
|
[("Op-Nemap", "Operatie nemapata")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
# Trimit form FARA cod_prestatie (lista goala)
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert _row(sid)["status"] == "needs_mapping", (
|
|
f"Status trebuia sa ramana needs_mapping, got {_row(sid)['status']}"
|
|
)
|
|
|
|
|
|
def test_idempotency_recalculat(client):
|
|
"""US-006 AC6: dupa setarea de coduri noi, cheia de idempotenta e recalculata.
|
|
|
|
RED: single-cod injecta in prestatii[0] si recalcula cheia; cu multi-cod
|
|
acelasi mecanism se aplica tuturor itemilor.
|
|
"""
|
|
acct = _create_account_user("ido.recalc@test.com")
|
|
_login(client, "ido.recalc@test.com")
|
|
_seed_cod("OE-1")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0ND001",
|
|
[("Op-Ido", "Operatie ido")],
|
|
))
|
|
old_key = _row(sid)["idempotency_key"]
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert _row(sid)["status"] == "queued"
|
|
new_key = _row(sid)["idempotency_key"]
|
|
assert new_key != old_key, (
|
|
f"Cheia de idempotenta NU s-a schimbat dupa setarea codului: {new_key}"
|
|
)
|
|
|
|
|
|
def test_odometru_initial_conditionat_R_ODO(client):
|
|
"""US-006 AC7: cod_prestatie=R-ODO fara odometruInitial -> validate_prezentare
|
|
intoarce eroare -> submission ramane needs_data (NU queued).
|
|
|
|
RED: validarea R-ODO e deja in validate_prezentare; testul confirma ca
|
|
multi-cod nu bypass-eaza aceasta regula.
|
|
"""
|
|
acct = _create_account_user("odo.rodo@test.com")
|
|
_login(client, "odo.rodo@test.com")
|
|
_seed_cod("R-ODO", "Revizie odometru")
|
|
|
|
# Payload: needs_mapping (op fara cod), FARA odometru_initial
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0RO001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
# odometru_initial ABSENT
|
|
"prestatii": [{"cod_op_service": "Revizie", "denumire": "Revizie odometru"}],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# Trimit R-ODO ca cod (valid in nomenclator), dar fara odometru_initial
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "cod_prestatie": "R-ODO"},
|
|
)
|
|
assert resp.status_code == 200
|
|
status = _row(sid)["status"]
|
|
# R-ODO fara odometruInitial -> validare esuata -> needs_data (nu queued)
|
|
assert status in ("needs_data", "needs_mapping"), (
|
|
f"Status neasteptat: {status}; trebuia needs_data/needs_mapping (R-ODO fara odo initial)"
|
|
)
|
|
assert status != "queued", (
|
|
"R-ODO fara odometruInitial NU trebuie sa treaca in queued!"
|
|
)
|
|
|
|
|
|
def test_dedup_per_item_nu_dupa_cod(client):
|
|
"""US-006 AC5 (E4): doua operatii DIFERITE cu ACELASI cod RAR ambele supravietuiesc.
|
|
|
|
Dedup = (op_service, cod) identice, NU cod singur. Doua ops distincte pot
|
|
mapa legitim la acelasi cod RAR fara sa fie sterse de dedup.
|
|
|
|
RED: dedupare naiva dupa cod ar sterge a doua operatie (op-B cu acelasi OE-1).
|
|
"""
|
|
acct = _create_account_user("dedup.ops@test.com")
|
|
_login(client, "dedup.ops@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
|
|
# Doua operatii distincte, ambele vor primi OE-1
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0DD001",
|
|
[("Op-A", "Prima operatie"), ("Op-B", "A doua operatie")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1", "OE-1"], # acelasi cod pentru ambele ops
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
# Ambele TREBUIE sa supravietuiasca: (Op-A, OE-1) != (Op-B, OE-1)
|
|
assert len(prestatii) == 2, (
|
|
f"Dedup a sters o operatie distincta! prestatii={prestatii} "
|
|
"(doua ops cu acelasi cod trebuie pastrate)"
|
|
)
|
|
ops = [p.get("cod_op_service") for p in prestatii]
|
|
assert "Op-A" in ops and "Op-B" in ops, f"ops_service pierdute: {ops}"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Test de regresie E1 (IRON RULE): op_service supravietuieste /repune cu cod #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_op_service_supravietuieste_repune_cu_cod(client):
|
|
"""E1 IRON RULE: dupa /repune cu cod_prestatie, cod_op_service/denumire RAMAN pe item.
|
|
|
|
RED: routes.py:1371 face `p0.pop("cod_op_service", None)` — sterge operatia
|
|
cand se seteaza un cod direct prin /repune. US-006 ELIMINA acel pop.
|
|
|
|
Aceasta regresie e CRITICA: sterge contextul op->cod necesar pentru US-009
|
|
(salvare mapare din chip) si rupe invariantul D7.
|
|
"""
|
|
acct = _create_account_user("e1.repune@test.com")
|
|
_login(client, "e1.repune@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei motor")
|
|
|
|
# Starea error: payload cu op_service (operatia venita de la import/API)
|
|
sid = _insert(acct, status="error", payload={
|
|
"vin": "WVWZZZ1JZXW0E1001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [{
|
|
"cod_op_service": "Schimb ulei",
|
|
"denumire": "Schimb ulei motor 5W30",
|
|
# fara cod_prestatie initial
|
|
}],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# /repune cu cod direct
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/repune",
|
|
data={"csrf_token": csrf, "cod_prestatie": "OE-1"},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:500]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
|
|
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
assert len(prestatii) == 1
|
|
item = prestatii[0]
|
|
|
|
# IRON RULE E1: op_service si denumire TREBUIE sa fie prezente
|
|
assert item.get("cod_op_service") == "Schimb ulei", (
|
|
f"E1 VIOLATED: cod_op_service a fost sters de /repune! item={item}"
|
|
)
|
|
assert item.get("denumire") == "Schimb ulei motor 5W30", (
|
|
f"E1 VIOLATED: denumire a fost stearsa de /repune! item={item}"
|
|
)
|
|
# Codul trebuie setat
|
|
assert item.get("cod_prestatie") == "OE-1", (
|
|
f"cod_prestatie nu a fost setat corect: item={item}"
|
|
)
|
|
|
|
|
|
def test_repune_nu_trunchiaza_prestatii_multiple(client):
|
|
"""Bug fix (code-review 5.15): /repune NU pierde prestatii[1:].
|
|
|
|
Formularul /repune trimite UN SINGUR select cod_prestatie. Implementarea veche
|
|
itera `enumerate(codes)` -> pastra doar len(codes) itemi, deci un rand error cu
|
|
2+ prestatii pierdea toate prestatiile dupa prima -> declaratie INCOMPLETA la RAR
|
|
(FINALIZATA ireversibil). Fix: iteram peste `existing`, aplicam codes pozitional,
|
|
pastram toate prestatiile.
|
|
|
|
RED inainte de fix: len(prestatii) == 1 (a doua prestatie pierduta).
|
|
"""
|
|
acct = _create_account_user("repune.multi@test.com")
|
|
_login(client, "repune.multi@test.com")
|
|
_seed_cod("AAA", "Prestatie A")
|
|
_seed_cod("BBB", "Prestatie B")
|
|
_seed_cod("CCC", "Prestatie C")
|
|
|
|
# Rand error cu DOUA prestatii (ambele cu cod valid).
|
|
sid = _insert(acct, status="error", payload={
|
|
"vin": "WVWZZZ1JZXW0RM001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [
|
|
{"cod_prestatie": "AAA"},
|
|
{"cod_prestatie": "BBB"},
|
|
],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# /repune cu UN SINGUR cod nou (schimba prima prestatie).
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/repune",
|
|
data={"csrf_token": csrf, "cod_prestatie": "CCC"},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:500]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", f"status neasteptat: {r['status']}"
|
|
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
assert len(prestatii) == 2, (
|
|
f"AMBELE prestatii trebuie pastrate de /repune, nu doar prima! got={prestatii}"
|
|
)
|
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
|
assert coduri == ["CCC", "BBB"], (
|
|
f"Codul nou se aplica POZITIONAL primei prestatii, a doua ramane intacta: {coduri}"
|
|
)
|
|
|
|
|
|
def test_corectie_eroare_validare_pastreaza_picker(client):
|
|
"""Bug fix (code-review 5.15): re-render-ul de eroare validare pastreaza optiunile pickerului.
|
|
|
|
post_corectie_trimitere re-randa _trimitere_detaliu pe ramura erori-validare FARA
|
|
`conn`/`account_id` -> `nomenclator_rar=[]` -> picker-ul chips randa ZERO optiuni ->
|
|
userul nu mai poate alege cod RAR fara sa inchida+redeschida modalul. Fix: pasam
|
|
`conn`+`account_id` la _detaliu_ctx pe TOATE ramurile de re-render.
|
|
|
|
RED inainte de fix: codul de picker "PK-1" lipseste din re-render.
|
|
"""
|
|
acct = _create_account_user("corectie.picker@test.com")
|
|
_login(client, "corectie.picker@test.com")
|
|
_seed_cod("ZZ-9", "Operatie existenta") # codul curent al randului (valid -> fara unmapped)
|
|
_seed_cod("PK-1", "Optiune picker") # cod doar in nomenclator (detector de picker)
|
|
|
|
# needs_data editabil, prestatie cu cod direct valid (resolve OK, fara unmapped).
|
|
sid = _insert(acct, status="needs_data", payload={
|
|
"vin": "WVWZZZ1JZXW0PK001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [{"cod_prestatie": "ZZ-9"}],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# Corectie cu VIN invalid -> validare esueaza -> ramura de re-render 1432.
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={"csrf_token": csrf, "vin": "BAD"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert _row(sid)["status"] == "needs_data"
|
|
# Picker-ul trebuie sa contina optiunile din nomenclator (conn/account_id pasate).
|
|
assert "PK-1" in resp.text, (
|
|
"Picker-ul chips e GOL dupa eroare de validare — _detaliu_ctx fara conn/account_id"
|
|
)
|
|
|
|
|
|
def test_repune_select_afiseaza_denumirea(client):
|
|
"""Bug fix (code-review 5.15): selectul /repune afiseaza denumirea operatiei.
|
|
|
|
Template-ul folosea cheia gresita `item.nome_prestatie` (typo) -> optiunile
|
|
apareau ca "AAA — " fara denumire. Cheia corecta e `nume_prestatie`.
|
|
"""
|
|
acct = _create_account_user("repune.denumire@test.com")
|
|
_login(client, "repune.denumire@test.com")
|
|
_seed_cod("AAA", "Schimb ulei motor")
|
|
|
|
sid = _insert(acct, status="error", payload={
|
|
"vin": "WVWZZZ1JZXW0RD001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [{"cod_prestatie": "AAA"}],
|
|
})
|
|
|
|
resp = client.get(f"/_fragments/trimitere/{sid}")
|
|
assert resp.status_code == 200
|
|
html = resp.text
|
|
# Optiunea trebuie sa afiseze denumirea, nu doar codul gol.
|
|
assert "Schimb ulei motor" in html, (
|
|
"Selectul /repune nu afiseaza denumirea operatiei (typo nome_prestatie)"
|
|
)
|
|
assert "AAA — Schimb ulei motor" in html, (
|
|
f"Optiunea select nu randeaza 'cod — denumire': {html[html.find('AAA'):html.find('AAA')+60]}"
|
|
)
|
|
|
|
|
|
# ============================================================================= #
|
|
# Teste noi 5.16: US-004 (denumiri picker), US-005 (add_extra), #
|
|
# US-006 (save picker fara buton), T-E3 (by-index), T-D1/T-E5, T-C1/T-E4 #
|
|
# ============================================================================= #
|
|
|
|
def test_picker_flat_arata_cod_si_denumire(client):
|
|
"""US-004 (5.16): picker plat afiseaza 'cod — denumire', nu doar codul.
|
|
|
|
RED: _chips_prestatii.html:147 afiseaza doar {{ n.cod_prestatie }};
|
|
modul operatii (:101) afiseaza deja 'cod — nume'. Fix: uniformizare.
|
|
"""
|
|
acct = _create_account_user("picker.flat.denu@test.com")
|
|
_login(client, "picker.flat.denu@test.com")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
|
|
# Submission flat: fara cod_op_service (mod plat)
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0US4001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [], # mod plat: fara operatii cu cod_op_service
|
|
})
|
|
|
|
resp = client.get(f"/_fragments/trimitere/{sid}")
|
|
assert resp.status_code == 200
|
|
# Optiunea trebuie sa arate 'FRN1 — Sistem de franare', nu doar 'FRN1'
|
|
assert "FRN1 — Sistem de franare" in resp.text, (
|
|
f"Picker plat nu arata denumirea: "
|
|
f"{resp.text[resp.text.find('FRN1'):resp.text.find('FRN1')+80] if 'FRN1' in resp.text else 'FRN1 absent'}"
|
|
)
|
|
|
|
|
|
def test_adauga_cod_extra_in_mod_operatii(client):
|
|
"""US-005 (5.16): in mod operatii, actiunea add_extra adauga un cod RAR liber.
|
|
|
|
RED: post_form_chips nu are actiunea 'add_extra' -> chips_action ignorata.
|
|
"""
|
|
acct = _create_account_user("add.extra.ops@test.com")
|
|
_login(client, "add.extra.ops@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei motor")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
csrf = _csrf(client)
|
|
|
|
# Chips stare: 1 operatie deja mapata (mod ops) → _has_ops = True
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1"], # chip existent (op mapata)
|
|
"chip_op_service": ["SchimbUlei"],
|
|
"chip_denumire": ["Schimb ulei motor"],
|
|
"chips_action": "add_extra",
|
|
"chips_add_cod_flat": "FRN1", # codul extra de adaugat
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:300]
|
|
# FRN1 trebuie sa apara in raspuns (chip extra adaugat)
|
|
assert "FRN1" in resp.text, (
|
|
f"Codul extra FRN1 nu a fost adaugat in mod operatii: {resp.text[:300]}"
|
|
)
|
|
# OE-1 trebuie sa ramana (chip original neatins)
|
|
assert "OE-1" in resp.text, f"Chip original OE-1 disparut: {resp.text[:300]}"
|
|
|
|
|
|
def test_extra_cod_persistat_la_salvare(client):
|
|
"""US-005 (5.16): codul extra adaugat via form-chips e salvat la /corecteaza.
|
|
|
|
Simulam starea form dupa add_extra: hidden inputs pentru op mapata (OE-1)
|
|
+ hidden inputs pentru chip extra flat (FRN1, fara op_service).
|
|
"""
|
|
acct = _create_account_user("extra.persist@test.com")
|
|
_login(client, "extra.persist@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload=_payload_cu_ops(
|
|
"WVWZZZ1JZXW0XP001",
|
|
[("SchimbUlei", "Schimb ulei motor")],
|
|
))
|
|
csrf = _csrf(client)
|
|
|
|
# Form state dupa add_extra: op mapata (idx=0, OE-1) + chip extra flat (idx=1, FRN1)
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1", "FRN1"], # OE-1 pt op, FRN1 chip extra
|
|
"chip_op_service": ["SchimbUlei", ""], # idx 0 are op_service, idx 1 nu
|
|
"chip_denumire": ["Schimb ulei motor", ""],
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:300]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", f"status asteptat queued, got {r['status']}"
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
|
assert "OE-1" in coduri, f"OE-1 (op mapata) lipsa: {prestatii}"
|
|
assert "FRN1" in coduri, f"FRN1 (chip extra) lipsa: {prestatii}"
|
|
|
|
|
|
def test_extra_cod_validat_nomenclator(client):
|
|
"""US-005 (5.16): add_extra respinge cod necunoscut in nomenclator (invariant ORA-12899).
|
|
|
|
RED: actiunea add_extra nu exista; dupa fix, cod invalid nu se adauga.
|
|
"""
|
|
acct = _create_account_user("extra.valid@test.com")
|
|
_login(client, "extra.valid@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
csrf = _csrf(client)
|
|
|
|
# add_extra cu cod INVALID (XX-99 nu e in nomenclator)
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1"],
|
|
"chip_op_service": ["SchimbUlei"],
|
|
"chip_denumire": ["Schimb ulei"],
|
|
"chips_action": "add_extra",
|
|
"chips_add_cod_flat": "XX-99", # cod necunoscut
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
html = resp.text
|
|
# XX-99 NU trebuie sa apara ca chip valid (hidden input cu valoarea XX-99)
|
|
import re as _re
|
|
hidden_xx99 = _re.search(r'<input[^>]+name="cod_prestatie"[^>]+value="XX-99"', html)
|
|
assert hidden_xx99 is None, (
|
|
f"Codul invalid XX-99 a fost adaugat ca chip! HTML: {html[:500]}"
|
|
)
|
|
|
|
|
|
def test_cod_ales_in_picker_se_salveaza_fara_buton_add(client):
|
|
"""US-006 (5.16): codul ales in picker flat se aplica la /corecteaza fara a apasa '+'.
|
|
|
|
RED: post_corectie_trimitere citeste form.getlist('cod_prestatie') (hidden inputs)
|
|
dar ignora 'chips_add_cod_flat' (picker neselectat ca chip) → submission ramane
|
|
needs_mapping desi codul e ales.
|
|
"""
|
|
acct = _create_account_user("picker.save.nobutton@test.com")
|
|
_login(client, "picker.save.nobutton@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei motor")
|
|
|
|
# Submission flat fara prestatii
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0PS001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [],
|
|
})
|
|
csrf = _csrf(client)
|
|
|
|
# Browser trimite chips_add_cod_flat=OE-1 (ales in picker) dar FARA hidden cod_prestatie
|
|
# (userul nu a apasat '+' sa promoveze selectia intr-un chip).
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_add_cod_flat": "OE-1", # ales in picker, ne-aprobat prin '+'
|
|
# NU exista 'cod_prestatie' in form (zero hidden chips)
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:300]
|
|
|
|
r = _row(sid)
|
|
assert r["status"] == "queued", (
|
|
f"Codul ales in picker trebuia sa se aplice la salvare fara '+': status={r['status']}"
|
|
)
|
|
prestatii = _payload_json(sid)["prestatii"]
|
|
coduri = [p.get("cod_prestatie") for p in prestatii]
|
|
assert "OE-1" in coduri, f"OE-1 (ales in picker) lipsa din prestatii: {prestatii}"
|
|
|
|
|
|
def test_salvare_fara_chip_explicit_nu_e_no_op(client):
|
|
"""US-006 (5.16): o trimitere needs_mapping cu cod ales in picker nu ramane no-op.
|
|
|
|
Complementar cu test_cod_ales_in_picker_se_salveaza_fara_buton_add: verifica
|
|
explicit ca statusul se schimba (nu ramane needs_mapping).
|
|
"""
|
|
acct = _create_account_user("noop.previne@test.com")
|
|
_login(client, "noop.previne@test.com")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0NP001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [],
|
|
})
|
|
old_status = _row(sid)["status"]
|
|
assert old_status == "needs_mapping"
|
|
csrf = _csrf(client)
|
|
|
|
resp = client.post(
|
|
f"/trimitere/{sid}/corecteaza",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"chips_add_cod_flat": "FRN1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
new_status = _row(sid)["status"]
|
|
assert new_status != "needs_mapping", (
|
|
f"Salvarea cu cod ales in picker trebuia sa nu fie no-op: status ramas {new_status}"
|
|
)
|
|
assert new_status == "queued", f"status asteptat queued, got {new_status}"
|
|
|
|
|
|
def test_picker_by_index_op2_nu_op1(client):
|
|
"""T-E3 (5.16): codul ales pe picker-ul op#2 aterizeaza pe op#2, NU pe op#1.
|
|
|
|
Verifica alinierea by-index in modul operatii: chips_add_op_index=1 + chips_add_cod_1
|
|
actualizeaza chips[1] (op#2), nu chips[0] (op#1).
|
|
"""
|
|
acct = _create_account_user("byindex.op2@test.com")
|
|
_login(client, "byindex.op2@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
_seed_cod("FRN1", "Sistem de franare")
|
|
csrf = _csrf(client)
|
|
|
|
# Chips: op#1 (idx=0) deja mapata cu OE-1, op#2 (idx=1) nemapata (cod gol)
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1", ""], # idx 0=OE-1 (mapata), idx 1="" (nemapata)
|
|
"chip_op_service": ["Op-A", "Op-B"],
|
|
"chip_denumire": ["Prima", "A doua"],
|
|
"chips_action": "add",
|
|
"chips_add_op_index": "1", # adauga pe op#2 (idx=1)
|
|
"chips_add_cod_1": "FRN1", # picker-ul op#2 contine FRN1
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text[:300]
|
|
html = resp.text
|
|
|
|
import re as _re
|
|
hidden_vals = _re.findall(r'<input[^>]+name="cod_prestatie"[^>]+value="([^"]*)"', html)
|
|
assert "OE-1" in hidden_vals, f"OE-1 (op#1) a disparut dupa adaugare pe op#2: {hidden_vals}"
|
|
assert "FRN1" in hidden_vals, f"FRN1 nu a aterizat pe op#2: {hidden_vals}"
|
|
# By-index: OE-1 trebuie sa fie INAINTE de FRN1 (idx 0 < idx 1)
|
|
oe1_pos = hidden_vals.index("OE-1") if "OE-1" in hidden_vals else -1
|
|
frn1_pos = hidden_vals.index("FRN1") if "FRN1" in hidden_vals else -1
|
|
assert oe1_pos < frn1_pos, (
|
|
f"FRN1 (op#2, idx=1) trebuie dupa OE-1 (op#1, idx=0) by-index: {hidden_vals}"
|
|
)
|
|
|
|
|
|
def test_empty_state_picker_nomenclator_gol(client):
|
|
"""T-D1/T-E5 (5.16): empty-state vizibil cand nomenclatorul e gol.
|
|
|
|
RED: {% if nomenclator_rar %} fara {% else %} -> silentios; un rand needs_mapping
|
|
fara nomenclator nu are nicio cale de a adauga cod (nereparabil silentios).
|
|
GREEN: div.chips-nom-gol vizibil.
|
|
"""
|
|
acct = _create_account_user("empty.nom@test.com")
|
|
_login(client, "empty.nom@test.com")
|
|
# Golim nomenclatorul: seed_nomenclator_if_empty populeaza la initializare DB;
|
|
# testul simuleaza cazul extrem cand tabla e goala (post-update, inainte de re-seed).
|
|
from app.db import get_connection as _gconn
|
|
_c = _gconn()
|
|
_c.execute("DELETE FROM nomenclator_rar")
|
|
_c.commit()
|
|
_c.close()
|
|
|
|
sid = _insert(acct, status="needs_mapping", payload={
|
|
"vin": "WVWZZZ1JZXW0EN001",
|
|
"nr_inmatriculare": "B100AAA",
|
|
"data_prestatie": "2026-06-10",
|
|
"odometru_final": "50000",
|
|
"prestatii": [],
|
|
})
|
|
|
|
resp = client.get(f"/_fragments/trimitere/{sid}")
|
|
assert resp.status_code == 200
|
|
assert "chips-nom-gol" in resp.text, (
|
|
f"Empty state 'chips-nom-gol' lipsa cand nomenclatorul e gol: {resp.text[resp.text.find('chips'):resp.text.find('chips')+200] if 'chips' in resp.text else resp.text[:500]}"
|
|
)
|
|
|
|
|
|
def test_add_extra_semnal_vizibil_cod_invalid(client):
|
|
"""T-C1/T-E4 (5.16): add_extra cu cod invalid da semnal vizibil (nu esua silentios).
|
|
|
|
RED: actiunea add_extra nu exista → nu exista niciun semnal.
|
|
GREEN: div.chips-extra-error vizibil cand codul e invalid sau selectul e gol.
|
|
"""
|
|
acct = _create_account_user("extra.err.signal@test.com")
|
|
_login(client, "extra.err.signal@test.com")
|
|
_seed_cod("OE-1", "Schimb ulei")
|
|
csrf = _csrf(client)
|
|
|
|
# add_extra cu cod necunoscut in nomenclator
|
|
resp = client.post(
|
|
"/form-chips",
|
|
data={
|
|
"csrf_token": csrf,
|
|
"cod_prestatie": ["OE-1"],
|
|
"chip_op_service": ["SchimbUlei"],
|
|
"chip_denumire": ["Schimb ulei"],
|
|
"chips_action": "add_extra",
|
|
"chips_add_cod_flat": "XX-99", # cod inexistent
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "chips-extra-error" in resp.text, (
|
|
f"Semnalul 'chips-extra-error' lipsa pentru cod invalid: {resp.text[:300]}"
|
|
)
|