Inchide bucla de trimitere (plan.md sect. 4 worker, failure registry).
- app/reconcile.py: match_finalizata pe vin+dataPrestatie+odometruFinal (int),
alege id maxim la duplicate (RAR accepta duplicate, confirmat live)
- app/rar_client.get_finalizate: parseaza data.content (descoperit live ca
ruta = GET /prezentari/getAllPrezentariFinalizate; filtrele nu merg pe test)
- app/worker rescris:
- recuperare orfane (rand 'sending' peste lease = worker mort mid-POST)
- pe eroare tranzitorie/timeout: reconciliere INAINTE de re-send (anti-duplicat);
daca recordul exista la RAR -> sent fara re-POST
- retry/backoff exponential; peste worker_max_retries -> error + banner
- re-login la token expirat (JWT 30h)
- schema: coloana next_attempt_at (backoff) + migrare aditiva in init_db
- config: worker_sending_lease_s, worker_retry_base_s/max_s, worker_max_retries
- contract: documentata ruta+forma getAllPrezentariFinalizate (verificat live)
Verify: pytest 54 passed (15 noi T2) + validare live (reconciliere record 68514).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
8.1 KiB
Python
224 lines
8.1 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_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
|