feat(5.19): auto-send toggle per cont + tinere manuala randuri (held)

Comutator accounts.auto_send_enabled per cont: Auto OFF (default) tine randurile
la ingestie (submissions.held=1), worker-ul (claim_one AND held=0) le sare pana la
eliberare umana (per rand/bulk/auto-release OFF->ON). Snapshot held prin chokepoint
unic held_for_account pe toate caile de ingestie (API, import, reresolve, reactivare).

- schema/migrare: coloana held + index partial idx_submissions_held; auto_send_enabled
- API: echo onest held+motiv (US-010), ruta /prezentari/{id}/trimite-acum
- web: toggle header, modal confirmare tipata, buton Trimite per rand + Trimite toate,
  banner coada tinuta imbatranita (L.142), contor "In asteptare (manual)"
- worker: expire_held (US-008, inchide gaura retentie PII), metrics held gauges
- ops: tools/carantina_held + runbook rollback (R4)

Nota review (/code-review high): re-snapshot held lipseste pe caile repune/corectie
(requeue_submission, post_corectie, bulk-fix) — de aliniat separat cu create_prezentari.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-07-06 08:34:40 +00:00
parent 2ec3292382
commit 3b5cf7a7d9
30 changed files with 2344 additions and 32 deletions

View File

@@ -28,6 +28,69 @@ os.environ.setdefault("AUTOPASS_EMBEDDINGS_ENABLED", "false")
os.environ.setdefault("AUTOPASS_SEED_OPERATII_ENABLED", "false")
# Referinta la init_db-ul REAL (nemodificat), capturata O SINGURA DATA la prima rulare a
# fixturii de mai jos. Wrapper-ul o refoloseste ca sa nu se auto-imbrace recursiv.
_REAL_INIT_DB = None
@pytest.fixture(autouse=True)
def _auto_send_on_default_account(request, monkeypatch):
"""PRD 5.19 US-009: contul default id=1 (seed schema.sql, INSERT OR IGNORE) porneste pe
`auto_send_enabled=0` (Auto OFF) — corect in productie (nimic nu pleaca fara confirmare).
Dar suita exercita lantul `POST -> claim_one -> sent` pe contul id=1
(test_import_e2e, test_creds_delivery, test_live_rar): cu Auto OFF, ingestia ar marca
`held=1` (US-002) si worker-ul (`claim_one ... AND held=0`, US-003) nu ar mai lua randul
-> testele ar stagna tacit. E un fix de STARE DB, nu env var (coloana e per-rand in
`accounts`), deci il aplicam wraparind `init_db` (contul id=1 NU e creat de `create_account`
-> un patch pe factory NU l-ar acoperi — Eng HIGH).
Wrapam `init_db` astfel incat orice DB de test creat sa aiba id=1 pe Auto ON (held=0 la
ingestie), pastrand suita verde ca inainte de 5.19. Conturile create explicit de teste
(`create_account`) raman pe default OFF -> testele care VOR Auto OFF il primesc corect.
Testele care verifica DEFAULT-ul brut (id=1 == 0 imediat dupa init_db) se exclud cu
markerul `no_auto_send_seed`.
"""
global _REAL_INIT_DB
import sys
import app.db as _db
if _REAL_INIT_DB is None:
_REAL_INIT_DB = _db.init_db
if request.node.get_closest_marker("no_auto_send_seed"):
yield
return
def _init_db_auto_on():
_REAL_INIT_DB()
conn = _db.get_connection()
try:
conn.execute("UPDATE accounts SET auto_send_enabled=1 WHERE id=1")
conn.commit()
finally:
conn.close()
# app.db.init_db: acopera fixturile care fac `from app.db import init_db` in corpul lor
# (importul ruleaza DUPA acest patch -> primeste wrapper-ul) + apelurile `app.db.init_db()`.
monkeypatch.setattr(_db, "init_db", _init_db_auto_on)
# app.main.init_db: lifespan-ul TestClient cheama numele importat la nivel de modul
# (`from .db import init_db`); daca app.main e deja importat, patch-uim si referinta lui.
# Daca NU e inca importat, primul `from app.main import app` din test il va lega la
# wrapper-ul de mai sus automat (citeste app.db.init_db la momentul importului).
_main = sys.modules.get("app.main")
if _main is not None:
monkeypatch.setattr(_main, "init_db", _init_db_auto_on, raising=False)
yield
# Anti-leak: daca app.main a fost importat IN timpul testului (legand wrapper-ul la
# import, nu prin monkeypatch), readu-l la init_db-ul real ca sa nu ramana wrapat global.
_main = sys.modules.get("app.main")
if _main is not None and getattr(_main, "init_db", None) is _init_db_auto_on:
_main.init_db = _REAL_INIT_DB
@pytest.fixture(autouse=True)
def _reset_embeddings_singleton():
"""Reseteaza singleton-ul global de embeddings intre teste (izolare de ordine).
@@ -69,3 +132,8 @@ def pytest_configure(config):
config.addinivalue_line(
"markers", "live: test live pe RAR test (necesita AUTOPASS_LIVE_RAR=1 + creds reale)"
)
config.addinivalue_line(
"markers",
"no_auto_send_seed: dezactiveaza fixtura autouse care pune id=1 pe Auto ON "
"(teste care verifica default-ul brut auto_send_enabled=0 al contului id=1)",
)

View File

@@ -0,0 +1,150 @@
"""US-001 (PRD 5.19): schema comutator Auto per cont + flag `held` pe submission.
Acopera: migrarea aditiva idempotenta (coloane + index partial pe DB vechi), helperii
`get_auto_send`/`set_auto_send`/`held_for_account` din app/accounts.py si default-ul 0.
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
import pytest
@pytest.fixture()
def fresh_conn(monkeypatch):
"""DB nou cu schema curenta (init_db)."""
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()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _old_db(path: str) -> sqlite3.Connection:
"""DB in forma PRE-5.19: accounts fara auto_send_enabled, submissions fara held."""
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute(
"CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, "
"cui TEXT)"
)
conn.execute(
"CREATE TABLE submissions (id INTEGER PRIMARY KEY AUTOINCREMENT, "
"idempotency_key TEXT NOT NULL UNIQUE, account_id INTEGER, status TEXT, "
"payload_json TEXT NOT NULL)"
)
return conn
def _migrate_old(path: str, monkeypatch) -> sqlite3.Connection:
from app.config import get_settings
get_settings.cache_clear()
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
from app.db import _migrate
_migrate(conn)
return conn
def test_migrare_adauga_coloane_si_index(tmp_path, monkeypatch):
"""Pe DB vechi (fara coloane) migrarea adauga held, auto_send_enabled si indexul partial."""
path = str(tmp_path / "old.db")
_old_db(path).close()
conn = _migrate_old(path, monkeypatch)
sub = {r["name"] for r in conn.execute("PRAGMA table_info(submissions)").fetchall()}
assert "held" in sub
acc = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "auto_send_enabled" in acc
idx = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='submissions'"
).fetchall()}
assert "idx_submissions_held" in idx
conn.close()
def test_migrare_idempotenta_a_doua_rulare(tmp_path, monkeypatch):
"""A doua rulare _migrate pe DB deja migrat nu arunca si nu dubleaza nimic."""
path = str(tmp_path / "old.db")
_old_db(path).close()
conn = _migrate_old(path, monkeypatch)
from app.db import _migrate
_migrate(conn) # nu arunca
acc = {r["name"] for r in conn.execute("PRAGMA table_info(accounts)").fetchall()}
assert "auto_send_enabled" in acc
conn.close()
def test_migrare_idempotenta_pe_db_fresh(fresh_conn):
"""_migrate re-rulat pe schema curenta (init_db) nu strica nimic."""
from app.db import _migrate
_migrate(fresh_conn)
sub = {r["name"] for r in fresh_conn.execute("PRAGMA table_info(submissions)").fetchall()}
assert "held" in sub
def test_held_for_account_auto_on(fresh_conn):
from app.accounts import create_account, held_for_account, set_auto_send
aid = create_account(fresh_conn, "Auto ON")
set_auto_send(fresh_conn, aid, True)
assert held_for_account(fresh_conn, aid) == 0
def test_held_for_account_auto_off(fresh_conn):
from app.accounts import create_account, held_for_account
aid = create_account(fresh_conn, "Auto OFF")
assert held_for_account(fresh_conn, aid) == 1
def test_held_for_account_cont_inexistent(fresh_conn):
from app.accounts import held_for_account
assert held_for_account(fresh_conn, 99999) == 1
def test_get_set_auto_send_round_trip(fresh_conn):
from app.accounts import create_account, get_auto_send, set_auto_send
aid = create_account(fresh_conn, "Round trip")
assert get_auto_send(fresh_conn, aid) is False
set_auto_send(fresh_conn, aid, True)
assert get_auto_send(fresh_conn, aid) is True
# Idempotent: acelasi set nu arunca si pastreaza valoarea.
set_auto_send(fresh_conn, aid, True)
assert get_auto_send(fresh_conn, aid) is True
set_auto_send(fresh_conn, aid, False)
assert get_auto_send(fresh_conn, aid) is False
def test_get_auto_send_cont_inexistent(fresh_conn):
from app.accounts import get_auto_send
assert get_auto_send(fresh_conn, 99999) is False
@pytest.mark.no_auto_send_seed # verifica default-ul BRUT id=1==0; nu wrapa init_db (conftest)
def test_default_auto_send_zero(fresh_conn):
"""Cont nou (create_account) si contul id=1 pornesc pe auto_send_enabled=0 (Auto OFF)."""
from app.accounts import create_account
aid = create_account(fresh_conn, "Nou")
row = fresh_conn.execute(
"SELECT auto_send_enabled FROM accounts WHERE id=?", (aid,)
).fetchone()
assert row["auto_send_enabled"] == 0
row1 = fresh_conn.execute(
"SELECT auto_send_enabled FROM accounts WHERE id=1"
).fetchone()
assert row1["auto_send_enabled"] == 0
def test_config_held_zile():
from app.config import Settings
s = Settings()
assert s.held_warn_days == 7
assert s.held_retention_days == 90

View File

@@ -0,0 +1,99 @@
"""Teste PRD 5.19 R4 — tools/carantina_held.py (atenuare hazard de rollback).
Carantineaza randurile `queued AND held=1` (-> `error`/`ROLLBACK_QUARANTINE`)
ca sa nu fie trimise de un worker fara filtrul `AND held=0` dupa un revert.
`--dry-run` doar numara, fara sa scrie.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
@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()
yield conn
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"}],
}
def _insert(conn, status="queued", held=0):
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, account_id, held) "
"VALUES (?, ?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(_CONTENT), 1, held),
)
return int(cur.lastrowid)
def _row(conn, sid):
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
def _run(argv):
from tools.carantina_held import main
return main(argv)
def test_carantineaza_rand_tinut(env, capsys):
conn = env
sid = _insert(conn, status="queued", held=1)
rc = _run([])
out = capsys.readouterr().out
assert rc == 0
assert "1 randuri carantinate" in out
r = _row(conn, sid)
assert r["status"] == "error"
assert r["rar_error"] == "ROLLBACK_QUARANTINE"
def test_dry_run_nu_modifica(env, capsys):
conn = env
sid = _insert(conn, status="queued", held=1)
rc = _run(["--dry-run"])
out = capsys.readouterr().out
assert rc == 0
assert "dry-run" in out
assert "1 randuri" in out
r = _row(conn, sid)
assert r["status"] == "queued" # neschimbat
assert r["rar_error"] is None
def test_nu_atinge_randuri_ne_tinute_sau_ne_queued(env, capsys):
conn = env
sid_libera = _insert(conn, status="queued", held=0)
sid_sent = _insert(conn, status="sent", held=1) # held dar deja plecat
sid_tinuta = _insert(conn, status="queued", held=1)
rc = _run([])
capsys.readouterr()
assert rc == 0
assert _row(conn, sid_libera)["status"] == "queued"
assert _row(conn, sid_sent)["status"] == "sent"
assert _row(conn, sid_tinuta)["status"] == "error"
def test_fara_randuri_tinute(env, capsys):
_insert(env, status="queued", held=0)
rc = _run([])
out = capsys.readouterr().out
assert rc == 0
assert "Niciun rand tinut" in out

146
tests/test_held_api_echo.py Normal file
View File

@@ -0,0 +1,146 @@
"""Teste PRD 5.19 US-010 — onestitate + observabilitate `held` pe canalul API.
- `held` in raspunsul enqueue (SubmissionResult) + motiv non-null.
- `held` in proiectiile GET /v1/prezentari si /v1/prezentari/{id}.
- endpoint de eliberare API POST /v1/prezentari/{id}/trimite-acum (paritate cu /repune).
"""
from __future__ import annotations
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "echo.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _set_auto_send(enabled: bool, account_id: int = 1) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET auto_send_enabled=? WHERE id=?",
(1 if enabled else 0, account_id),
)
conn.commit()
finally:
conn.close()
def _held(sid: int) -> int:
from app.db import get_connection
conn = get_connection()
try:
return conn.execute("SELECT held FROM submissions WHERE id=?", (sid,)).fetchone()["held"]
finally:
conn.close()
def _body(**over) -> dict:
prez = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
prez.update(over)
return {"prezentari": [prez]}
def _enqueue_held(client) -> int:
_set_auto_send(False)
r = client.post("/v1/prezentari", json=_body())
return r.json()["results"][0]["submission_id"]
# --------------------------------------------------------------------------- #
# GET proiectii #
# --------------------------------------------------------------------------- #
def test_get_prezentare_expune_held(client):
sid = _enqueue_held(client)
r = client.get(f"/v1/prezentari/{sid}")
assert r.status_code == 200, r.text
body = r.json()
assert "held" in body
assert body["held"] in (1, True)
def test_list_prezentari_expune_held(client):
sid = _enqueue_held(client)
r = client.get("/v1/prezentari")
assert r.status_code == 200, r.text
rows = {row["id"]: row for row in r.json()["submissions"]}
assert sid in rows
assert "held" in rows[sid]
assert rows[sid]["held"] in (1, True)
# --------------------------------------------------------------------------- #
# POST /v1/prezentari/{id}/trimite-acum #
# --------------------------------------------------------------------------- #
def test_trimite_acum_elibereaza(client):
"""Rand tinut -> trimite-acum -> held=0."""
sid = _enqueue_held(client)
assert _held(sid) == 1
r = client.post(f"/v1/prezentari/{sid}/trimite-acum")
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
assert _held(sid) == 0
def test_trimite_acum_id_inexistent_404(client):
r = client.post("/v1/prezentari/999999/trimite-acum")
assert r.status_code == 404
def test_trimite_acum_rand_nequeued_noop(client):
"""Rand non-queued (sent) -> no-op sigur, ramane neschimbat, raspuns 200."""
_set_auto_send(True)
sid = client.post("/v1/prezentari", json=_body()).json()["results"][0]["submission_id"]
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE submissions SET status='sent', held=1 WHERE id=?", (sid,))
conn.commit()
finally:
conn.close()
r = client.post(f"/v1/prezentari/{sid}/trimite-acum")
assert r.status_code == 200, r.text
# held nemodificat (nu era queued)
assert _held(sid) == 1
def test_trimite_acum_cont_strain_404(client):
"""Un rand al altui cont -> 404-before-leak (nu elibereaza cross-account)."""
sid = _enqueue_held(client)
from app.accounts import create_account
from app.db import get_connection
conn = get_connection()
try:
# Cont real strain (id>=2) + muta randul pe el (FK valid).
other = create_account(conn, "Alt cont")
conn.execute("UPDATE submissions SET account_id=? WHERE id=?", (other, sid))
conn.commit()
finally:
conn.close()
# Clientul (dev, fara cheie) e pe contul id=1 -> randul altui cont e 404-before-leak.
r = client.post(f"/v1/prezentari/{sid}/trimite-acum")
assert r.status_code == 404
assert _held(sid) == 1

259
tests/test_held_ingestie.py Normal file
View File

@@ -0,0 +1,259 @@
"""Teste PRD 5.19 US-002 — snapshot `held` la ingestie din comutatorul de cont.
Acopera TOATE situ-rile care scriu `status='queued'` prin ingestie (chokepoint
`held_for_account`): enqueue API, reactivare error->queued, dedup echo, commit import,
reresolve `needs_mapping`. `held` NU intra in idempotency_key.
Contul default id=1 e fortat pe Auto ON de fixtura autouse din conftest; testele care
vor Auto OFF il pun explicit pe 0 (`_set_auto_send(client, False)`).
"""
from __future__ import annotations
import io
import os
import tempfile
import openpyxl
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "held.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
from app.config import get_settings
get_settings.cache_clear()
from app.main import app
with TestClient(app) as c:
yield c
get_settings.cache_clear()
def _set_auto_send(enabled: bool, account_id: int = 1) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"UPDATE accounts SET auto_send_enabled=? WHERE id=?",
(1 if enabled else 0, account_id),
)
conn.commit()
finally:
conn.close()
def _row(sid: int) -> dict:
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
return dict(r) if r else {}
finally:
conn.close()
def _force_status(sid: int, status: str) -> None:
from app.db import get_connection
conn = get_connection()
try:
conn.execute("UPDATE submissions SET status=? WHERE id=?", (status, sid))
conn.commit()
finally:
conn.close()
def _body(**over) -> dict:
prez = {
"vin": "WVWZZZ1KZAW000123",
"nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15",
"odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
prez.update(over)
return {"prezentari": [prez]}
# --------------------------------------------------------------------------- #
# Enqueue API #
# --------------------------------------------------------------------------- #
def test_auto_off_ingestie_held(client):
"""Cont Auto OFF -> POST valid -> rand queued, held=1; raspuns held=true + motiv non-null."""
_set_auto_send(False)
r = client.post("/v1/prezentari", json=_body())
assert r.status_code == 200, r.text
res = r.json()["results"][0]
assert res["status"] == "queued"
assert res["held"] is True
assert res["motiv"], "motiv trebuie sa fie non-null cand randul e tinut"
assert _row(res["submission_id"])["held"] == 1
def test_auto_on_ingestie_not_held(client):
"""Cont Auto ON -> POST valid -> rand queued, held=0; raspuns held=false, motiv None."""
_set_auto_send(True)
r = client.post("/v1/prezentari", json=_body())
assert r.status_code == 200, r.text
res = r.json()["results"][0]
assert res["status"] == "queued"
assert res["held"] is False
assert res["motiv"] is None
assert _row(res["submission_id"])["held"] == 0
# --------------------------------------------------------------------------- #
# Reactivare error->queued (bug de bypass Eng Finding A) #
# --------------------------------------------------------------------------- #
def test_reactivare_respecta_auto_off(client):
"""Un rand `error` re-POST-at pe cont Auto OFF re-intra queued CU held=1 (nu pastreaza vechiul)."""
_set_auto_send(True)
r = client.post("/v1/prezentari", json=_body())
sid = r.json()["results"][0]["submission_id"]
assert _row(sid)["held"] == 0
# Contul trece pe OFF si randul cade in error; re-POST-ul trebuie sa-l tina.
_set_auto_send(False)
_force_status(sid, "error")
r2 = client.post("/v1/prezentari", json=_body())
res = r2.json()["results"][0]
assert res["submission_id"] == sid
assert res["reactivated"] is True
assert res["held"] is True
assert res["motiv"]
assert _row(sid)["held"] == 1
# --------------------------------------------------------------------------- #
# Dedup echo #
# --------------------------------------------------------------------------- #
def test_dedup_echo_held(client):
"""Re-POST acelasi continut (Auto OFF) -> deduped, dar held propagat corect (nu queued curat fals)."""
_set_auto_send(False)
r1 = client.post("/v1/prezentari", json=_body())
sid = r1.json()["results"][0]["submission_id"]
r2 = client.post("/v1/prezentari", json=_body())
res = r2.json()["results"][0]
assert res["submission_id"] == sid
assert res["deduped"] is True
assert res["held"] is True
assert res["motiv"]
# --------------------------------------------------------------------------- #
# Idempotenta: held nu schimba cheia #
# --------------------------------------------------------------------------- #
def test_held_nu_schimba_idempotency_key(client):
"""Acelasi continut Auto ON vs OFF -> aceeasi idempotency_key (held e pur coada)."""
_set_auto_send(True)
sid_on = client.post("/v1/prezentari", json=_body()).json()["results"][0]["submission_id"]
key_on = _row(sid_on)["idempotency_key"]
# Simulam un al doilea DB logic: acelasi continut, cont OFF -> dedup pe aceeasi cheie.
_set_auto_send(False)
r2 = client.post("/v1/prezentari", json=_body())
res2 = r2.json()["results"][0]
assert res2["deduped"] is True
assert res2["submission_id"] == sid_on
assert _row(res2["submission_id"])["idempotency_key"] == key_on
# --------------------------------------------------------------------------- #
# Reresolve needs_mapping -> queued #
# --------------------------------------------------------------------------- #
def test_reresolve_held(client):
"""Cont Auto OFF, submission needs_mapping -> reresolve dupa mapare -> queued cu held=1."""
_set_auto_send(False)
# Cod intern necunoscut -> needs_mapping (nu se trimite).
body = _body(prestatii=[{"cod_op_service": "REV-INTERN", "denumire": "Revizie interna"}])
r = client.post("/v1/prezentari", json=body)
res = r.json()["results"][0]
sid = res["submission_id"]
assert _row(sid)["status"] == "needs_mapping"
# Salvare mapare -> reresolve automat (POST /v1/mapari cheama reresolve_account).
rc = client.post("/v1/mapari", json={
"cod_op_service": "REV-INTERN", "cod_prestatie": "OE-1", "auto_send": True,
})
assert rc.status_code == 200, rc.text
row = _row(sid)
assert row["status"] == "queued"
assert row["held"] == 1
def test_reresolve_held_auto_on(client):
"""Cont Auto ON, reresolve -> queued cu held=0."""
_set_auto_send(True)
body = _body(prestatii=[{"cod_op_service": "REV-INTERN", "denumire": "Revizie interna"}])
sid = client.post("/v1/prezentari", json=body).json()["results"][0]["submission_id"]
assert _row(sid)["status"] == "needs_mapping"
client.post("/v1/mapari", json={
"cod_op_service": "REV-INTERN", "cod_prestatie": "OE-1", "auto_send": True,
})
row = _row(sid)
assert row["status"] == "queued"
assert row["held"] == 0
# --------------------------------------------------------------------------- #
# Commit import #
# --------------------------------------------------------------------------- #
_HEADER = ["VIN", "Nr inmatriculare", "Data prestatie", "Odometru final", "Operatie"]
_ROW = ["WVWZZZ1KZAW001111", "B100TST", "2026-06-15", "123456", "Revizie"]
_COLMAP = {
"VIN": "vin", "Nr inmatriculare": "nr_inmatriculare",
"Data prestatie": "data_prestatie", "Odometru final": "odometru_final",
"Operatie": "operatie",
}
def _make_xlsx(rows: list[list]) -> bytes:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Sheet1"
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _commit_import(client) -> int:
data = _make_xlsx([_HEADER, _ROW])
import_id = client.post(
"/v1/import",
files={"file": ("t.xlsx", io.BytesIO(data), "application/octet-stream")},
).json()["import_id"]
client.post(f"/v1/import/{import_id}/column-mapping", json={"json_mapare": _COLMAP})
client.post("/v1/mapari", json={"cod_op_service": "Revizie", "cod_prestatie": "OE-1", "auto_send": True})
rp = client.get(f"/v1/import/{import_id}/preview")
assert rp.status_code == 200 and rp.json()["summary"].get("ok", 0) == 1, rp.text
rc = client.post(f"/v1/import/{import_id}/commit", json={
"n_confirmat": 1, "reviewed_rows": [], "confirmed_by": "t@e2e.ro",
})
assert rc.status_code == 200, rc.text
return rc.json()["submissions"][0]["submission_id"]
def test_import_commit_held_auto_off(client):
"""Commit import pe cont Auto OFF -> rand queued cu held=1."""
_set_auto_send(False)
sid = _commit_import(client)
row = _row(sid)
assert row["status"] == "queued"
assert row["held"] == 1
def test_import_commit_not_held_auto_on(client):
"""Commit import pe cont Auto ON -> rand queued cu held=0."""
_set_auto_send(True)
sid = _commit_import(client)
row = _row(sid)
assert row["status"] == "queued"
assert row["held"] == 0

88
tests/test_labels_held.py Normal file
View File

@@ -0,0 +1,88 @@
"""
Teste pentru eticheta randurilor tinute manual (held) — US-006, PRD 5.19.
RED intai: scrise inainte de a extinde labels.py cu parametrul `held`.
Regula de afisaj (D5, PRD 5.19):
- `status='queued' AND held=1` -> "In asteptare (manual)" + clasa CSS de
AVERTIZARE (amber, `--warn`) — asteptare benigna, NU eroare (rosu).
- `held=0` (sau lipsa) -> comportament neschimbat pentru TOATE starile.
- `held` NU e stare noua in masina de stari: CHECK-ul din schema ramane neatins.
Fisiere atinse: app/web/labels.py, tests/test_labels_held.py.
"""
import pytest
from app.web.labels import eticheta_scurta, eticheta_stare
TEXT_HELD = "In asteptare (manual)"
# Clasa CSS amber (--warn) reutilizata din base.html (.s-needs_review{color:var(--warn)}).
# NU e o clasa needs_* rosie (s-needs_data / s-needs_mapping sunt --err).
CLASA_WARN_HELD = "s-needs_review"
# Toate starile non-queued: held nu trebuie sa le schimbe niciodata.
STARI_NEAFECTATE = ["sending", "sent", "needs_mapping", "needs_data", "error"]
# ---------------------------------------------------------------------------
# eticheta_stare (text lung + subtext + clasa)
# ---------------------------------------------------------------------------
def test_queued_held_are_eticheta_manuala_si_clasa_warn():
text, subtext, css_class = eticheta_stare("queued", held=True)
assert text == TEXT_HELD, f"asteptam {TEXT_HELD!r}, got {text!r}"
assert css_class == CLASA_WARN_HELD, (
f"randul tinut trebuie amber (--warn) prin {CLASA_WARN_HELD!r}, got {css_class!r}"
)
# Nu trebuie sa fie o clasa de eroare rosie.
assert css_class not in ("s-error", "s-needs_data", "s-needs_mapping"), (
"held e asteptare benigna — nu trebuie sa foloseasca o clasa rosie de eroare"
)
assert isinstance(subtext, str)
def test_queued_held_false_ramane_neschimbat():
"""queued fara held == comportamentul dinainte de US-006 (PRD 5.19)."""
fara_held = eticheta_stare("queued")
held_false = eticheta_stare("queued", held=False)
assert fara_held == held_false, "held=False trebuie identic cu apelul fara held"
text, _subtext, css_class = held_false
assert "In asteptare" in text
assert text != TEXT_HELD, "queued normal NU trebuie sa afiseze varianta manuala"
assert css_class == "s-queued"
@pytest.mark.parametrize("status", STARI_NEAFECTATE)
def test_alte_stari_ignora_held(status):
"""held nu are efect pe nicio stare in afara de queued."""
assert eticheta_stare(status, held=True) == eticheta_stare(status, held=False)
assert eticheta_stare(status, held=True) == eticheta_stare(status)
def test_apel_pozitional_compat():
"""Semnatura pastreaza compatibilitatea: apelul vechi cu un singur arg merge."""
assert eticheta_stare("sent") == eticheta_stare("sent", held=False)
# ---------------------------------------------------------------------------
# eticheta_scurta (pill) — pill-ul trebuie sa distinga held de queued normal (D5)
# ---------------------------------------------------------------------------
def test_pill_queued_held_diferit_de_queued_normal():
pill_held = eticheta_scurta("queued", held=True)
pill_normal = eticheta_scurta("queued", held=False)
assert pill_held != pill_normal, (
"pill-ul trebuie sa distinga randul tinut (D5: altfel randa 'In coada' identic)"
)
assert pill_normal == "In coada"
def test_pill_alte_stari_ignora_held():
for status in STARI_NEAFECTATE:
assert eticheta_scurta(status, held=True) == eticheta_scurta(status, held=False)
def test_pill_apel_pozitional_compat():
assert eticheta_scurta("queued") == eticheta_scurta("queued", held=False)

View File

@@ -0,0 +1,97 @@
"""Teste PRD 5.19 US-007 — gauge-uri /metrics pentru coada tinuta.
/metrics expune, pe langa `autopass_submissions{status=...}`, doua gauge-uri
DERIVATE (zero stare noua), scoped global:
- autopass_held_submissions = COUNT(queued AND held=1)
- autopass_held_oldest_age_seconds = varsta (s) a celui mai vechi rand tinut
TDD: fixtura izoleaza DB per test (pattern din test_worker_held.py).
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@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()
yield conn
conn.close()
get_settings.cache_clear()
def _client():
from app.main import app
return TestClient(app)
_CONTENT = {
"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}],
}
def _insert(conn, status="queued", held=0, created_at=None):
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, account_id, held) "
"VALUES (?, ?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(_CONTENT), 1, held),
)
sid = int(cur.lastrowid)
if created_at is not None:
conn.execute("UPDATE submissions SET created_at=? WHERE id=?", (created_at, sid))
return sid
def _metric(text, name):
for line in text.splitlines():
if line.startswith(name + " "):
return line[len(name) + 1:].strip()
return None
def test_held_gauges_numara_si_varsta(env):
conn = env
# doua randuri tinute (unul vechi de ~10 zile) + unul ne-tinut
_insert(conn, status="queued", held=1, created_at="2026-06-25 00:00:00")
_insert(conn, status="queued", held=1)
_insert(conn, status="queued", held=0)
body = _client().get("/metrics").text
assert _metric(body, "autopass_held_submissions") == "2"
oldest = int(_metric(body, "autopass_held_oldest_age_seconds"))
assert oldest > 0
def test_held_gauge_zero_fara_randuri_tinute(env):
conn = env
_insert(conn, status="queued", held=0)
_insert(conn, status="sent", held=0)
body = _client().get("/metrics").text
assert _metric(body, "autopass_held_submissions") == "0"
# varsta = 0 cand nu exista randuri tinute (COALESCE)
assert _metric(body, "autopass_held_oldest_age_seconds") == "0"
def test_held_gauge_ignora_tinut_ne_queued(env):
conn = env
# held=1 dar status != queued (ex. deja carantinat) NU se numara
_insert(conn, status="error", held=1)
_insert(conn, status="queued", held=1)
body = _client().get("/metrics").text
assert _metric(body, "autopass_held_submissions") == "1"

347
tests/test_web_auto_send.py Normal file
View File

@@ -0,0 +1,347 @@
"""Teste PRD 5.19 strat WEB: toggle "Trimite automat la RAR" (US-004), trimitere
manuala per rand + bulk (US-005), afisaj held (US-006 UI), banner coada tinuta
imbatranita + contor manual (US-007), audit app_events (US-009).
Model: TestClient + login web (ca tests/test_web_lifecycle.py). NU atinge conftest.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "as.db"))
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
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()
def _account_user(email, name="Service", password="parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
aid = create_account(conn, name, active=True)
create_user(conn, aid, email, password)
return aid
finally:
conn.close()
def _login(client, email, password="parolasecreta10"):
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)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": m.group(1)})
assert resp.status_code == 303, resp.text[:200]
return _csrf(client)
def _csrf(client):
resp = client.get("/?tab=acasa")
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, "csrf_token negasit dupa login"
return m.group(1)
def _ins(account_id, status="queued", held=0, created_at=None):
from app.db import get_connection
conn = get_connection()
try:
content = {"vin": "WVWZZZ1KZAW000123", "nr_inmatriculare": "B999TST",
"data_prestatie": "2026-06-15", "odometru_final": "123456",
"prestatii": [{"cod_prestatie": "OE-1"}]}
if created_at is None:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, held) "
"VALUES (?, ?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", account_id, status, json.dumps(content), held),
)
else:
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json, held, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(f"k-{os.urandom(6).hex()}", account_id, status, json.dumps(content), held, created_at),
)
conn.commit()
return int(cur.lastrowid)
finally:
conn.close()
def _get_auto_send(account_id):
from app.db import get_connection
conn = get_connection()
try:
r = conn.execute("SELECT auto_send_enabled FROM accounts WHERE id=?", (account_id,)).fetchone()
return int(r["auto_send_enabled"])
finally:
conn.close()
def _held(sid):
from app.db import get_connection
conn = get_connection()
try:
return int(conn.execute("SELECT held FROM submissions WHERE id=?", (sid,)).fetchone()["held"])
finally:
conn.close()
# --------------------------------------------------------------------------- #
# US-004 — toggle Auto: persistenta + CSRF + scope #
# --------------------------------------------------------------------------- #
def test_auto_send_persista(client):
aid = _account_user("a1@test.com")
csrf = _login(client, "a1@test.com")
assert _get_auto_send(aid) == 0
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
# Debifare
r = client.post("/auto-send", data={"csrf_token": csrf}) # enabled absent = OFF
assert r.status_code == 200
assert _get_auto_send(aid) == 0
def test_auto_send_csrf(client):
aid = _account_user("a2@test.com")
_login(client, "a2@test.com")
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": "gresit"})
assert r.status_code == 403
assert _get_auto_send(aid) == 0
def test_auto_send_scoped_nu_atinge_alt_cont(client):
aid = _account_user("a3@test.com", name="A3")
other = _account_user("a3b@test.com", name="A3B")
csrf = _login(client, "a3@test.com")
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
# contul celalalt ramane OFF (account_id vine din sesiune, nu din formular)
assert _get_auto_send(other) == 0
def test_auto_send_scoped_ignora_account_id_din_formular(client):
aid = _account_user("a4@test.com", name="A4")
other = _account_user("a4b@test.com", name="A4B")
csrf = _login(client, "a4@test.com")
# incearca sa forteze alt cont prin formular -> trebuie ignorat
r = client.post("/auto-send", data={"enabled": "1", "account_id": str(other), "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
assert _get_auto_send(other) == 0
# --------------------------------------------------------------------------- #
# US-004 — auto-release OFF->ON cu garda de confirmare #
# --------------------------------------------------------------------------- #
def test_off_to_on_fara_confirmare_nu_elibereaza(client):
aid = _account_user("g1@test.com")
csrf = _login(client, "g1@test.com")
sid = _ins(aid, "queued", held=1)
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert r.status_code == 200
# fara confirmare: NU comita ON, NU elibereaza
assert _get_auto_send(aid) == 0
assert _held(sid) == 1
# raspunsul cere confirmare (modal)
assert "confirm" in r.text.lower() or "trimit" in r.text.lower()
def test_off_to_on_cu_confirmare_elibereaza_scoped(client):
aid = _account_user("g2@test.com", name="G2")
other = _account_user("g2b@test.com", name="G2B")
csrf = _login(client, "g2@test.com")
s1 = _ins(aid, "queued", held=1)
s2 = _ins(aid, "queued", held=1)
s_other = _ins(other, "queued", held=1)
r = client.post("/auto-send", data={"enabled": "1", "confirma": "1", "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
assert _held(s1) == 0
assert _held(s2) == 0
# cross-account izolare: randul altui cont ramane tinut
assert _held(s_other) == 1
def test_off_to_on_fara_randuri_tinute_comite_direct(client):
aid = _account_user("g3@test.com")
csrf = _login(client, "g3@test.com")
# niciun rand tinut -> nu are nevoie de confirmare
r = client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert r.status_code == 200
assert _get_auto_send(aid) == 1
# --------------------------------------------------------------------------- #
# US-005 — trimitere manuala per rand + bulk #
# --------------------------------------------------------------------------- #
def test_trimite_acum_rand_tinut(client):
aid = _account_user("t1@test.com")
csrf = _login(client, "t1@test.com")
sid = _ins(aid, "queued", held=1)
r = client.post(f"/trimitere/{sid}/trimite-acum", data={"csrf_token": csrf})
assert r.status_code == 200
assert _held(sid) == 0
def test_trimite_acum_cross_account_404(client):
aid = _account_user("t2@test.com", name="T2")
other = _account_user("t2b@test.com", name="T2B")
csrf = _login(client, "t2@test.com")
sid_other = _ins(other, "queued", held=1)
r = client.post(f"/trimitere/{sid_other}/trimite-acum", data={"csrf_token": csrf})
assert r.status_code == 404
assert _held(sid_other) == 1
def test_trimite_acum_non_queued_noop(client):
aid = _account_user("t3@test.com")
csrf = _login(client, "t3@test.com")
sid = _ins(aid, "sent", held=0)
r = client.post(f"/trimitere/{sid}/trimite-acum", data={"csrf_token": csrf})
assert r.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
assert conn.execute("SELECT status FROM submissions WHERE id=?", (sid,)).fetchone()["status"] == "sent"
finally:
conn.close()
def test_trimite_acum_csrf(client):
aid = _account_user("t4@test.com")
_login(client, "t4@test.com")
sid = _ins(aid, "queued", held=1)
r = client.post(f"/trimitere/{sid}/trimite-acum", data={"csrf_token": "gresit"})
assert r.status_code == 403
assert _held(sid) == 1
def test_trimite_toate_scoped(client):
aid = _account_user("t5@test.com", name="T5")
other = _account_user("t5b@test.com", name="T5B")
csrf = _login(client, "t5@test.com")
s1 = _ins(aid, "queued", held=1)
s2 = _ins(aid, "queued", held=1)
s_other = _ins(other, "queued", held=1)
r = client.post("/trimite-toate", data={"csrf_token": csrf})
assert r.status_code == 200
assert _held(s1) == 0
assert _held(s2) == 0
assert _held(s_other) == 1
# --------------------------------------------------------------------------- #
# US-006 (UI) — eticheta held + buton Trimite in lista #
# --------------------------------------------------------------------------- #
def test_rand_tinut_eticheta_si_buton(client):
aid = _account_user("u1@test.com")
_login(client, "u1@test.com")
sid_tinut = _ins(aid, "queued", held=1)
html = client.get("/_fragments/submissions").text
assert "In asteptare (manual)" in html or "Manual" in html
assert f"/trimitere/{sid_tinut}/trimite-acum" in html
def test_rand_ne_tinut_fara_buton(client):
aid = _account_user("u2@test.com")
_login(client, "u2@test.com")
sid = _ins(aid, "queued", held=0)
html = client.get("/_fragments/submissions").text
assert f"/trimitere/{sid}/trimite-acum" not in html
# --------------------------------------------------------------------------- #
# US-007 — banner coada tinuta imbatranita + contor manual #
# --------------------------------------------------------------------------- #
def test_banner_aged_apare_pt_rand_vechi(client):
aid = _account_user("b1@test.com")
_login(client, "b1@test.com")
_ins(aid, "queued", held=1, created_at="2026-06-01 08:00:00") # >7 zile fata de 2026-07-05
html = client.get("/_fragments/status").text
assert "declarare obligatorie" in html.lower() or "tinute de" in html.lower()
def test_banner_aged_absent_pt_rand_recent(client):
aid = _account_user("b2@test.com")
_login(client, "b2@test.com")
from datetime import datetime, timezone
_ins(aid, "queued", held=1, created_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
html = client.get("/_fragments/status").text
assert "declarare obligatorie" not in html.lower()
def test_contor_manual_apare(client):
aid = _account_user("b3@test.com")
_login(client, "b3@test.com")
_ins(aid, "queued", held=1)
_ins(aid, "queued", held=1)
html = client.get("/_fragments/status").text
assert "manual" in html.lower()
# --------------------------------------------------------------------------- #
# US-009 — audit app_events #
# --------------------------------------------------------------------------- #
def _events(account_id, tip):
from app.db import get_connection
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM app_events WHERE tip=? AND account_id=?", (tip, account_id)
).fetchall()
return rows
finally:
conn.close()
def test_audit_auto_send_schimbat(client):
aid = _account_user("j1@test.com")
csrf = _login(client, "j1@test.com")
client.post("/auto-send", data={"enabled": "1", "csrf_token": csrf})
assert len(_events(aid, "auto_send_schimbat")) >= 1
def test_audit_held_eliberat_rand(client):
aid = _account_user("j2@test.com")
csrf = _login(client, "j2@test.com")
sid = _ins(aid, "queued", held=1)
client.post(f"/trimitere/{sid}/trimite-acum", data={"csrf_token": csrf})
assert len(_events(aid, "held_eliberat")) >= 1
def test_audit_held_eliberat_bulk(client):
aid = _account_user("j3@test.com")
csrf = _login(client, "j3@test.com")
_ins(aid, "queued", held=1)
_ins(aid, "queued", held=1)
client.post("/trimite-toate", data={"csrf_token": csrf})
evs = _events(aid, "held_eliberat")
assert len(evs) >= 1

204
tests/test_worker_held.py Normal file
View File

@@ -0,0 +1,204 @@
"""Teste PRD 5.19 US-003 + US-008 — worker respecta flag `held`.
US-003: claim_one sare peste randurile `queued AND held=1` (tinute manual).
US-008: expire_held expira randurile tinute mai vechi de `held_retention_days`
(default 90) -> status 'error', rar_error='TINUT_EXPIRAT', purge_after
DIRECT la momentul expirarii (NU +30z retentie blocati -> nu 120z total),
apoi purge_expired le sterge (PII purjat).
TDD: testele se scriu INAINTE de modificarea worker-ului.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
# --- Fixture DB (pattern din test_worker_active_gate.py) ---
@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()
yield conn, get_settings()
conn.close()
get_settings.cache_clear()
# --- Helpers ---
_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, account_id=None, status="queued", held=0, content=None, created_at=None):
content = content or _CONTENT
cur = conn.execute(
"INSERT INTO submissions (idempotency_key, status, payload_json, account_id, held) "
"VALUES (?, ?, ?, ?, ?)",
(f"key-{os.urandom(4).hex()}", status, json.dumps(content), account_id, held),
)
sid = int(cur.lastrowid)
if created_at is not None:
conn.execute("UPDATE submissions SET created_at=? WHERE id=?", (created_at, sid))
return sid
def _row(conn, sid):
return conn.execute("SELECT * FROM submissions WHERE id=?", (sid,)).fetchone()
def _row_status(conn, sid):
r = _row(conn, sid)
return r["status"] if r else None
# --- US-003: claim_one sare peste randurile tinute ---
def test_claim_sare_rand_tinut(env):
"""queued, held=1, cont activ, send on -> claim_one intoarce None; ramane queued."""
from app.accounts import create_account
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Tinut", active=True)
sid = _insert(conn, account_id=acct_id, held=1)
result = claim_one(conn)
assert result is None, "claim_one trebuia sa sara peste randul tinut (held=1)"
assert _row_status(conn, sid) == "queued", "randul tinut trebuia sa ramana queued"
def test_claim_ia_rand_neted(env):
"""Acelasi rand cu held=0 -> claim_one il ia si il marcheaza sending."""
from app.accounts import create_account
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Liber", active=True)
sid = _insert(conn, account_id=acct_id, held=0)
result = claim_one(conn)
assert result is not None, "claim_one trebuia sa ridice randul held=0"
assert result["id"] == sid
assert _row_status(conn, sid) == "sending"
def test_eliberarea_deblocheaza_claim(env):
"""held=1 -> claim_one None; dupa held=0 -> claim_one il ridica."""
from app.accounts import create_account
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Eliberat", active=True)
sid = _insert(conn, account_id=acct_id, held=1)
assert claim_one(conn) is None
assert _row_status(conn, sid) == "queued"
conn.execute("UPDATE submissions SET held=0 WHERE id=?", (sid,))
result = claim_one(conn)
assert result is not None and result["id"] == sid
assert _row_status(conn, sid) == "sending"
# --- US-008: expirare randuri tinute ---
def test_expire_held_expira_rand_vechi(env):
"""queued, held=1, created_at vechi (>90z) -> error + TINUT_EXPIRAT + purge_after ~now."""
from app.accounts import create_account
from app.worker.__main__ import expire_held
conn, settings = env
acct_id = create_account(conn, "Service Vechi", active=True)
sid = _insert(conn, account_id=acct_id, held=1,
created_at="datetime('now','-100 days')")
# created_at trebuie sa fie o valoare literala, nu expresie SQL text -> setam explicit.
conn.execute("UPDATE submissions SET created_at=datetime('now','-100 days') WHERE id=?", (sid,))
n = expire_held(conn, settings)
assert n == 1, "expire_held trebuia sa expire exact 1 rand"
row = _row(conn, sid)
assert row["status"] == "error"
assert row["rar_error"] == "TINUT_EXPIRAT"
assert row["purge_after"] is not None
# purge_after ~ now (NU +30z): verificam ca e <= acum + o mica marja (sub 1 zi).
aproape_acum = conn.execute(
"SELECT purge_after <= datetime('now', '+1 day') AS ok FROM submissions WHERE id=?",
(sid,),
).fetchone()["ok"]
assert aproape_acum == 1, "purge_after trebuia setat ~now, nu +30z"
assert row["sending_since"] is None
def test_expire_held_ignora_rand_recent(env):
"""queued, held=1 recent (created_at now) -> neatins de expire_held."""
from app.accounts import create_account
from app.worker.__main__ import expire_held
conn, settings = env
acct_id = create_account(conn, "Service Recent", active=True)
sid = _insert(conn, account_id=acct_id, held=1)
n = expire_held(conn, settings)
assert n == 0
assert _row_status(conn, sid) == "queued"
def test_expire_held_ignora_rand_neted_vechi(env):
"""queued, held=0, vechi -> NU e tinut -> neatins de expire_held."""
from app.accounts import create_account
from app.worker.__main__ import expire_held
conn, settings = env
acct_id = create_account(conn, "Service NetedVechi", active=True)
sid = _insert(conn, account_id=acct_id, held=0)
conn.execute("UPDATE submissions SET created_at=datetime('now','-100 days') WHERE id=?", (sid,))
n = expire_held(conn, settings)
assert n == 0
assert _row_status(conn, sid) == "queued"
def test_expire_held_apoi_purge_sterge_randul(env):
"""Lant complet: expire_held (purge_after ~now) -> purge_expired sterge randul (PII purjat)."""
from app.accounts import create_account
from app.worker.__main__ import expire_held, purge_expired
conn, settings = env
acct_id = create_account(conn, "Service Purjat", active=True)
sid = _insert(conn, account_id=acct_id, held=1)
conn.execute("UPDATE submissions SET created_at=datetime('now','-100 days') WHERE id=?", (sid,))
assert expire_held(conn, settings) == 1
# purge_after=datetime('now') (moment expirarii). La URMATORUL ciclu de purjare
# (o data pe ora) purge_after < now -> se sterge. Simulam trecerea timpului
# (>= 1s) verificand ca purge_after e deja in trecutul acelui ciclu ulterior.
assert conn.execute(
"SELECT purge_after < datetime('now','+1 second') AS ok FROM submissions WHERE id=?",
(sid,),
).fetchone()["ok"] == 1
conn.execute(
"UPDATE submissions SET purge_after=datetime('now','-1 second') WHERE id=?", (sid,)
)
stats = purge_expired(conn)
assert stats["submissions_purged"] >= 1
assert _row(conn, sid) is None, "randul expirat trebuia sters de purge_expired la ciclul ulterior"