"""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_rar_500_definitiv_devine_error_fara_reconciliere(env): """RAR 500 cu mesaj (ex. ORA-12899) = esec DEFINITIV: NU reconcilia, NU reincerca. Bug-ul real: 500 era tratat ca tranzitoriu -> reconciliere -> marca fals 'sent' pe un record PARTIAL creat de RAR (ne-tranzactional). Aici, chiar daca exista un record care s-ar potrivi, NU trebuie marcat sent. """ from app.worker.__main__ import process_one conn, settings = env sid = _insert(conn) rar = FakeRar( finalizate=[{"id": 99999, "vin": "WVWZZZ1KZAW000123", "dataPrestatie": "2026-06-15", "odometruFinal": 123456}], post_exc=RarError( "postPrezentare esuat (HTTP 500)", status_code=500, rar_message="Eroare la adaugarea prezentarii : ORA-12899: value too large for column COD_PRESTATIE", ), ) out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT}) assert out == "error" row = _status(conn, sid) assert row["status"] == "error" assert row["id_prezentare"] is None # NU fals sent assert rar.post_calls == 1 # nu re-trimite assert rar.finalizate_calls == 0 # NU reconciliaza err = json.loads(row["rar_error"]) assert err["cod"] == "RAR_EROARE_SERVER" and "ORA-12899" in err["cauza"] def test_rar_503_ramane_tranzitoriu(env): """Boundary: doar 500-cu-mesaj e permanent; 503 (infra) ramane ambiguu -> reconciliere.""" from app.worker.__main__ import process_one conn, settings = env sid = _insert(conn) rar = FakeRar( finalizate=[{"id": 68514, "vin": "WVWZZZ1KZAW000123", "dataPrestatie": "2026-06-15", "odometruFinal": 123456}], post_exc=RarError("service unavailable", status_code=503, rar_message="temporar indisponibil"), ) out = process_one(conn, settings, rar, "tok", {"id": sid, "content": _CONTENT}) assert out == "sent" assert _status(conn, sid)["id_prezentare"] == 68514 assert rar.finalizate_calls == 1 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