Files
rar-autopass/tests/test_worker_reconcile.py
Claude Agent c842e3352a feat(5.6): observabilitate + jurnal aplicatie + lifecycle trimiteri blocate
Implementeaza PRD 5.6 complet (14 stories, TDD). Doua axe:

Lifecycle trimiteri blocate (Val A):
- submissions_admin.py: sterge/repune scoped (404 cross-account inaintea lui 409 stare)
- reactivare dedup peste `error` cu CAS (WHERE id=? AND status='error'), creds noi in
  submissions + accounts.rar_creds_enc; worker invalideaza sesiunea RAR la creds proaspete
  (JWT 30h vechi nu mai trimite cu parola gresita); camp aditiv `reactivated:true`
- retentie randuri blocate 30z; purge_expired exclude queued/sending; purge_after curatat
  la reactivare/requeue
- API DELETE /v1/prezentari/{id} + /repune (200+JSON); UI butoane + bulk + banner actionabil

Observabilitate:
- app/observ.py log_event: dublu canal app_events (DB) + RotatingFileHandler per-proces,
  redactare creds/PII la scriere (redact_pii/vin_partial)
- request_id middleware + X-Request-ID pe toate raspunsurile
- handler global excepții -> 500 envelope 6-chei + request_id (traceback doar in jurnal)
- audit cerere API (api_prezentari/api_auth_esuat) + audit worker (rar_login/tranzitii)
- tab "Jurnal" filtrabil scoped (non-admin doar contul sau); retentie jurnal 90z
- rar_error expus in GET /v1/prezentari/{id} (recovery observabil)

pytest -q: 741 passed, 0 failed. Docs: PRD raport VERIFY, contract endpointuri noi, ROADMAP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:45:39 +00:00

263 lines
10 KiB
Python

"""Teste T2 — reconciliere anti-duplicat + retry/backoff + recuperare orfane.
Matcher pur (app.reconcile) + functiile worker-ului cu un RAR fake si DB temporara.
Testul-cheie: raspuns pierdut -> reconciliere gaseste recordul -> 'sent' fara re-POST.
"""
from __future__ import annotations
import json
import os
import tempfile
import httpx
import pytest
from app.rar_client import RarError
from app.reconcile import match_finalizata
# --- Matcher (unit) ---
_FIN = [
{"id": 63622, "vin": "WBA1234567890", "dataPrestatie": "2024-02-05", "odometruFinal": 125000},
{"id": 63625, "vin": "WBA1234567890", "dataPrestatie": "2024-02-05", "odometruFinal": 125000},
{"id": 68514, "vin": "WVWZZZ1KZAW000123", "dataPrestatie": "2026-06-15", "odometruFinal": 123456},
]
def test_match_gaseste_pe_vin_data_odo():
assert match_finalizata(_FIN, vin="WVWZZZ1KZAW000123", data_prestatie="2026-06-15", odometru_final="123456") == 68514
def test_match_vin_case_insensitive():
assert match_finalizata(_FIN, vin="wvwzzz1kzaw000123", data_prestatie="2026-06-15", odometru_final="123456") == 68514
def test_match_odo_string_vs_int():
# payload-ul nostru are odometruFinal string; RAR il intoarce int.
assert match_finalizata(_FIN, vin="WBA1234567890", data_prestatie="2024-02-05", odometru_final="125000") == 63625
def test_match_duplicate_alege_id_maxim():
# 63622 si 63625 sunt identice -> alege maximul.
assert match_finalizata(_FIN, vin="WBA1234567890", data_prestatie="2024-02-05", odometru_final=125000) == 63625
def test_match_fara_potrivire():
assert match_finalizata(_FIN, vin="WVWZZZ1KZAW000123", data_prestatie="2026-06-15", odometru_final="999") is None
def test_match_data_diferita():
assert match_finalizata(_FIN, vin="WBA1234567890", data_prestatie="2025-01-01", odometru_final="125000") is None
# --- Worker (integration cu FakeRar + DB temporara) ---
class FakeRar:
def __init__(self, *, finalizate=None, post_result=None, post_exc=None):
self.finalizate = finalizate or []
self.post_result = post_result if post_result is not None else {"id": 1000}
self.post_exc = post_exc
self.post_calls = 0
self.finalizate_calls = 0
def get_finalizate(self, token):
self.finalizate_calls += 1
return self.finalizate
def post_prezentare(self, token, payload):
self.post_calls += 1
if self.post_exc is not None:
raise self.post_exc
return self.post_result
@pytest.fixture()
def env(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
conn = get_connection()
settings = get_settings()
yield conn, settings
conn.close()
get_settings.cache_clear()
_CONTENT = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}], "sistem_reparat": "null",
}
def _insert(conn, status="queued", content=None, sending_since=None, retry_count=0):
content = content or _CONTENT
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, sending_since, retry_count) "
"VALUES (?, ?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(content), sending_since, retry_count),
)
return int(cur.lastrowid)
def _status(conn, sid):
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
def test_happy_path_sent(env):
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
rar = FakeRar(post_result={"id": 555})
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "sent"
row = _status(conn, sid)
assert row["status"] == "sent" and row["id_prezentare"] == 555
assert rar.post_calls == 1
def test_raspuns_pierdut_reconciliere_fara_duplicat(env):
"""POST esueaza tranzitoriu DAR RAR a inregistrat -> reconciliere -> sent, fara re-POST."""
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
# 503 = tranzitoriu; recordul EXISTA deja la RAR (raspunsul s-a pierdut).
rar = FakeRar(
finalizate=[{"id": 68514, "vin": "WVWZZZ1KZAW000123", "dataPrestatie": "2026-06-15", "odometruFinal": 123456}],
post_exc=RarError("502 bad gateway", status_code=503),
)
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "sent"
row = _status(conn, sid)
assert row["status"] == "sent" and row["id_prezentare"] == 68514
assert rar.post_calls == 1 # NU s-a re-trimis
assert rar.finalizate_calls == 1 # a reconciliat
def test_tranzitoriu_neinregistrat_requeue(env):
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
rar = FakeRar(finalizate=[], post_exc=httpx.ConnectError("conn refused"))
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "requeued"
row = _status(conn, sid)
assert row["status"] == "queued"
assert row["retry_count"] == 1
assert row["next_attempt_at"] is not None
def test_validare_400_needs_data(env):
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
rar = FakeRar(post_exc=RarError("Validare esuata", status_code=400,
field_errors=[{"field": "vin", "message": "rau"}]))
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "needs_data"
assert _status(conn, sid)["status"] == "needs_data"
assert rar.post_calls == 1
def test_4xx_nerecuperabil_error(env):
from app.worker.__main__ import process_one
conn, settings = env
sid = _insert(conn)
rar = FakeRar(post_exc=RarError("forbidden", status_code=403))
out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT})
assert out == "error"
assert _status(conn, sid)["status"] == "error"
def test_retry_peste_maxim_devine_error(env):
from app.worker.__main__ import requeue_with_backoff
conn, settings = env
sid = _insert(conn, retry_count=settings.worker_max_retries) # urmatorul requeue depaseste maximul
requeue_with_backoff(conn, settings, sid, reason="tranzitoriu persistent")
assert _status(conn, sid)["status"] == "error"
def test_recover_orphan_reconciliat(env):
from app.worker.__main__ import recover_orphans
conn, settings = env
# rand 'sending' vechi (lease depasit) -> orfan; recordul exista la RAR.
sid = _insert(conn, status="sending", sending_since="2000-01-01T00:00:00+00:00")
rar = FakeRar(finalizate=[{"id": 77777, "vin": "WVWZZZ1KZAW000123",
"dataPrestatie": "2026-06-15", "odometruFinal": 123456}])
n = recover_orphans(conn, settings, rar, "tok")
assert n == 1
row = _status(conn, sid)
assert row["status"] == "sent" and row["id_prezentare"] == 77777
def test_recover_orphan_neinregistrat_requeue(env):
from app.worker.__main__ import recover_orphans
conn, settings = env
sid = _insert(conn, status="sending", sending_since="2000-01-01T00:00:00+00:00")
rar = FakeRar(finalizate=[])
recover_orphans(conn, settings, rar, "tok")
assert _status(conn, sid)["status"] == "queued"
def test_recover_orphan_ignora_randul_proaspat_claim(env):
"""Regresie format data: un rand PROASPAT revendicat NU trebuie tratat ca orfan.
claim_one scrie sending_since cu datetime('now') -> 'YYYY-MM-DD HH:MM:SS' (spatiu).
Bug-ul: cutoff calculat cu _iso() era in format ISO ('T'), iar la comparatie de string
spatiul (0x20) < 'T' (0x54) facea ca ORICE rand 'sending' sa para <= cutoff -> lease-ul
de 120s era ignorat si fiecare rand revendicat era reconciliat/requeue-uit instant.
"""
from app.worker.__main__ import claim_one, recover_orphans
conn, settings = env
_insert(conn) # queued
claimed = claim_one(conn) # -> sending, sending_since=datetime('now')
assert claimed is not None
# Chiar daca recordul ar exista la RAR, randul proaspat nu trebuie atins.
rar = FakeRar(finalizate=[{"id": 999, "vin": "WVWZZZ1KZAW000123",
"dataPrestatie": "2026-06-15", "odometruFinal": 123456}])
n = recover_orphans(conn, settings, rar, "tok")
assert n == 0 # nu l-a recuperat
assert rar.finalizate_calls == 0 # nici macar nu a interogat RAR
assert _status(conn, claimed["id"])["status"] == "sending" # neatins
def test_recover_orphan_lease_depasit_format_sqlite(env):
"""Complementar: un rand 'sending' mai vechi decat lease-ul (format SQLite, spatiu) ESTE orfan."""
from app.worker.__main__ import recover_orphans
conn, settings = env
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, sending_since) "
"VALUES (?, 'sending', ?, datetime('now', '-1 hour'))",
(f"key-{os.urandom(4).hex()}", json.dumps(_CONTENT)),
)
sid = int(cur.lastrowid)
rar = FakeRar(finalizate=[{"id": 888, "vin": "WVWZZZ1KZAW000123",
"dataPrestatie": "2026-06-15", "odometruFinal": 123456}])
n = recover_orphans(conn, settings, rar, "tok")
assert n == 1
assert _status(conn, sid)["status"] == "sent"
def test_claim_respecta_next_attempt_at(env):
from app.worker.__main__ import claim_one
conn, _ = env
# next_attempt_at in viitor -> nu se ia.
conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, next_attempt_at) "
"VALUES ('k1','queued',?, '2999-01-01T00:00:00+00:00')",
(json.dumps(_CONTENT),),
)
assert claim_one(conn) is None
# next_attempt_at in trecut -> se ia.
conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, next_attempt_at) "
"VALUES ('k2','queued',?, '2000-01-01T00:00:00+00:00')",
(json.dumps(_CONTENT),),
)
claimed = claim_one(conn)
assert claimed is not None