rag-service/ocr-service/tests/test_api.py
Paco POR-CORREO 5fef85cfb2 feat(ocr): durable review indexing production wiring
Wire the complete OCR review and indexing production pipeline:
durable OCR result handoff before remote deletion (17a), native-page
evidence and quality-gated candidate composition (17b), review images
and restart-safe candidate loading (17c), transactional approval and
rejection decisions (17d), reviewed-artifact indexing store with exact
count verification (17e), and production approve-to-ready wiring with
fail-closed OCR_INDEXING_UNAVAILABLE (17f). Activation remains a
separately authorized operation; task 7.4 stays pending.
2026-09-16 18:35:18 +02:00

197 lines
9.1 KiB
Python

import hashlib
import json
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).parents[1]))
from app.main import create_app
from app.engine import EngineLine
TOKEN = "unit-5-test-token"
PDF = b"%PDF-1.4\nunit five\n%%EOF"
FIXTURE = Path(__file__).parents[2] / "tests" / "fixtures" / "ocr" / "native-three-pages.pdf"
class FakeEngine:
def recognize(self, _image: object) -> list[EngineLine]:
return [EngineLine("Factura FAT07", 0.97, (10, 20, 160, 50))]
def request_for(pdf: bytes = PDF, pages: list[int] | None = None) -> dict:
return {
"documentSha256": hashlib.sha256(pdf).hexdigest(),
"pages": pages or [1, 2],
"languages": ["es", "en"],
"dpi": 200,
"engine": "paddleocr",
"engineVersion": "3.4.0",
"runtimeVersion": "3.2.2",
"configVersion": "ocr-v1",
"returnLayout": True,
}
def key_for(request: dict) -> str:
pages = json.dumps(request["pages"], separators=(",", ":")).encode()
return f'{request["documentSha256"]}:ocr-v1:{hashlib.sha256(pages).hexdigest()}'
def submit(client: TestClient, request: dict, pdf: bytes = PDF, key: str | None = None):
return client.post(
"/v1/jobs",
headers={"Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key or key_for(request)},
files={
"file": ("input.pdf", pdf, "application/pdf"),
"request": (None, json.dumps(request), "application/json"),
},
)
@pytest.fixture
def client(tmp_path: Path) -> TestClient:
return TestClient(create_app(TOKEN, tmp_path / "jobs.db", engine_ready=True))
def test_auth_rejects_missing_and_wrong_bearer_and_health_exposes_no_secret(client: TestClient):
live = client.get("/health/live")
ready = client.get("/health/ready")
assert live.json() == {"status": "ok"}
assert ready.json() == {"ready": True, "queueDepth": 0, "queueCapacity": 3, "concurrency": 1}
for authorization in (None, "Bearer wrong-token"):
headers = {"Idempotency-Key": key_for(request_for())}
if authorization:
headers["Authorization"] = authorization
response = client.post(
"/v1/jobs",
headers=headers,
files={"file": ("input.pdf", PDF, "application/pdf"), "request": (None, json.dumps(request_for()))},
)
assert response.status_code == 401
assert response.json()["detail"]["retryable"] is False
assert TOKEN not in response.text
unavailable = TestClient(create_app(TOKEN, ":memory:", engine_ready=False)).get("/health/ready")
assert unavailable.status_code == 503
assert unavailable.json()["ready"] is False
def test_auth_submission_is_idempotent_and_conflicting_payload_is_terminal(client: TestClient):
request = request_for()
first = submit(client, request)
repeated = submit(client, request)
conflict = request_for(pages=[1])
conflicting = submit(client, conflict, key=key_for(request))
assert first.status_code == repeated.status_code == 202
assert first.json() == repeated.json()
assert first.json()["requestedPages"] == [1, 2]
assert conflicting.status_code == 409
assert conflicting.json()["detail"] == {
"code": "IDEMPOTENCY_CONFLICT",
"message": "The idempotency key is already bound to another request",
"retryable": False,
}
@pytest.mark.parametrize(
("field", "value"),
[("languages", ["en"]), ("dpi", 300), ("engine", "tesseract"), ("returnLayout", False)],
)
def test_auth_allowlist_rejects_client_selected_configuration(client: TestClient, field: str, value: object):
request = request_for()
request[field] = value
response = submit(client, request, key="different-key")
assert response.status_code == 400
assert response.json()["detail"]["retryable"] is False
def test_auth_limits_corrupt_pdf_and_integrity_mismatch_are_terminal(tmp_path: Path):
client = TestClient(create_app(TOKEN, tmp_path / "limited.db", max_upload_bytes=8, engine_ready=True))
regular = TestClient(create_app(TOKEN, tmp_path / "regular.db", engine_ready=True))
oversized = submit(client, request_for(PDF), PDF)
too_many = submit(regular, request_for(pages=list(range(1, 102))))
corrupt = submit(regular, request_for(b"not-pdf"), b"not-pdf")
wrong_hash = request_for()
wrong_hash["documentSha256"] = "0" * 64
mismatch = submit(regular, wrong_hash, key=key_for(wrong_hash))
assert oversized.status_code == too_many.status_code == 413
assert corrupt.status_code == mismatch.status_code == 422
assert mismatch.json()["detail"]["code"] == "INTEGRITY_MISMATCH"
for response in (oversized, too_many, corrupt, mismatch):
assert response.json()["detail"]["retryable"] is False
assert "Retry-After" not in response.headers
def test_auth_queue_pressure_is_retryable_and_status_and_delete_are_authenticated(client: TestClient):
accepted = [submit(client, request_for(pdf), pdf) for pdf in (PDF, PDF + b"1", PDF + b"2")]
pressure = submit(client, request_for(PDF + b"3"), PDF + b"3")
assert [response.status_code for response in accepted] == [202, 202, 202]
assert pressure.status_code == 429
assert pressure.json()["detail"]["retryable"] is True
assert pressure.headers["Retry-After"] == "2"
job_id = accepted[0].json()["jobId"]
status = client.get(f"/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {TOKEN}"})
assert status.json() == {"jobId": job_id, "status": "queued", "completedPages": 0, "totalPages": 2, "error": None}
assert client.delete(f"/v1/jobs/{job_id}").status_code == 401
assert client.delete(f"/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {TOKEN}"}).status_code == 204
assert client.delete(f"/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {TOKEN}"}).status_code == 204
def test_auth_health_sweeps_only_jobs_older_than_24_hours(tmp_path: Path):
current = [datetime(2026, 9, 16, tzinfo=timezone.utc)]
db_path = tmp_path / "expiry.db"
client = TestClient(create_app(TOKEN, db_path, engine_ready=True, now=lambda: current[0]))
job_id = submit(client, request_for()).json()["jobId"]
headers = {"Authorization": f"Bearer {TOKEN}"}
current[0] += timedelta(hours=23, minutes=59)
assert client.get("/health/ready").status_code == 200
assert client.get(f"/v1/jobs/{job_id}", headers=headers).status_code == 200
current[0] += timedelta(minutes=2)
assert client.get("/health/ready").status_code == 200
assert client.get(f"/v1/jobs/{job_id}", headers=headers).status_code == 404
with sqlite3.connect(db_path) as connection:
assert connection.execute("SELECT count(*) FROM jobs").fetchone()[0] == 0
def test_auth_accepted_job_executes_and_exposes_integrity_bound_result(tmp_path: Path):
pdf = FIXTURE.read_bytes()
db_path = tmp_path / "executed.db"
with sqlite3.connect(db_path) as connection:
connection.execute("CREATE TABLE jobs (job_id TEXT PRIMARY KEY, idempotency_key TEXT UNIQUE, payload_hash TEXT, document_sha256 TEXT, pages TEXT, status TEXT, created_at TEXT)")
queued_client = TestClient(create_app(TOKEN, db_path, engine_ready=True))
submit(queued_client, request_for(pdf, [1]), pdf)
client = TestClient(create_app(TOKEN, db_path, engine_ready=True, engine=FakeEngine()))
accepted = submit(client, request_for(pdf, [1]), pdf)
job_id = accepted.json()["jobId"]
status = client.get(f"/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {TOKEN}"})
result = client.get(f"/v1/jobs/{job_id}/result", headers={"Authorization": f"Bearer {TOKEN}"})
assert status.json() == {"jobId": job_id, "status": "succeeded", "completedPages": 1, "totalPages": 1, "error": None}
assert result.status_code == 200
assert (result.json()["jobId"], result.json()["documentSha256"]) == (job_id, hashlib.sha256(pdf).hexdigest())
assert [(page["page"], page["text"]) for page in result.json()["pages"]] == [(1, "Factura FAT07")]
image = client.get(f"/v1/jobs/{job_id}/pages/1/image", headers={"Authorization": f"Bearer {TOKEN}"})
assert image.status_code == 200
assert image.headers["content-type"] == "image/png"
assert image.headers["x-document-sha256"] == hashlib.sha256(pdf).hexdigest()
assert image.headers["x-content-sha256"] == hashlib.sha256(image.content).hexdigest()
assert image.content.startswith(b"\x89PNG\r\n\x1a\n")
assert client.get(f"/v1/jobs/{job_id}/pages/1/image").status_code == 401
assert client.get(f"/v1/jobs/{job_id}/pages/4/image", headers={"Authorization": f"Bearer {TOKEN}"}).status_code == 422
def test_auth_result_rejects_unknown_not_ready_and_unauthorized_jobs(client: TestClient):
job_id = submit(client, request_for()).json()["jobId"]
headers = {"Authorization": f"Bearer {TOKEN}"}
assert client.get(f"/v1/jobs/{job_id}/result").status_code == 401
assert client.get("/v1/jobs/missing/result", headers=headers).json()["detail"]["code"] == "JOB_NOT_FOUND"
pending = client.get(f"/v1/jobs/{job_id}/result", headers=headers)
assert (pending.status_code, pending.json()["detail"]["code"]) == (409, "RESULT_NOT_READY")