Files
html-to-pdf/tests/test_jobs.py
T
JiriUhlirandClaude Opus 5 156289fe2d Implementace prevodu HTML na PDF
Sluzba prijme adresu HTML dokumentu nebo HTML v tele requestu a vrati PDF.
Navrzena pro dokumenty o stovkach az tisicich stranek.

Rendering:
- WeasyPrint jako vychozi engine, spravne CSS Paged Media, nizka pametova
  narocnost, bez JavaScriptu
- Chromium pres Playwright pro dokumenty dokreslovane skripty
- rezim auto s detekci skriptu a fallbackem pri selhani WeasyPrintu

Velke dokumenty:
- deleni na casti na strukturalnich hranicich, rez nikdy uvnitr tabulky
  nebo odstavce
- dvoupruchodovy render obsahu se skutecnymi cisly stranek, pozice nadpisu
  se ctou z kotev hlasenych u kazde stranky
- cislovani stranek bud pres CSS countery, nebo pres cislovaci vrstvu
  nastampovanou na hotove PDF, rozmer stranky se cte z vysledneho souboru
- Chromium se restartuje po N jobech, nikdy vsak behem beziciho renderu

API:
- POST /convert synchronne, POST /jobs asynchronne se sledovanim stavu,
  stahovanim vysledku, rusenim a volitelnym callbackem
- GET /health s overenim dostupnosti obou enginu a stavem fronty
- OpenAPI respektuje prefix reverse proxy pres root_path

Bezpecnost a provoz:
- SSRF kontrola po DNS resolvu, na kazdem presmerovani a u vsech pozadavku
  prohlizece
- nedostupne assety render nezastavi, ale hlasi se v odpovedi i v logu
- fronta s omezenym poctem workeru, rozpracovane joby se pri ukonceni
  oznaci jako failed, nezmizi potichu
- strukturovane JSON logovani s job_id
- vsechny limity vypnute ve vychozim stavu

Dockerfile je dvoufazovy, obsahuje zavislosti WeasyPrintu, Chromium
a fonty s ceskou diakritikou.

Autentizace zamerne neni implementovana, zpusob predavani neni domluveny.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 14:50:10 +02:00

134 lines
4.2 KiB
Python

"""Job queue behaviour."""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from app.config import Settings
from app.errors import LimitExceededError, QueueFullError
from app.models import ConvertRequest, Source
from app.services.jobs import JobManager
from app.services.pipeline import ConversionResult
from app.services.storage import Storage
pytestmark = pytest.mark.asyncio
class StubPipeline:
def __init__(self, behaviour="ok", delay=0.0) -> None:
self.behaviour = behaviour
self.delay = delay
async def run(self, request, workdir: Path, progress=None) -> ConversionResult:
if progress is not None:
progress(1, 1, 1, 1)
if self.delay:
await asyncio.sleep(self.delay)
if self.behaviour == "conversion_error":
raise LimitExceededError("Prekrocen limit stranek.", {"limit": 10})
if self.behaviour == "crash":
raise RuntimeError("necekana chyba enginu")
workdir.mkdir(parents=True, exist_ok=True)
produced = workdir / "out.pdf"
produced.write_bytes(b"%PDF-1.7\n%fake\n")
return ConversionResult(path=produced, page_count=1, engine_used="stub")
def make_manager(tmp_path, behaviour="ok", delay=0.0, **overrides) -> JobManager:
settings = Settings()
for key, value in overrides.items():
object.__setattr__(settings, key, value)
object.__setattr__(settings, "storage_dir", str(tmp_path))
storage = Storage(str(tmp_path))
return JobManager(StubPipeline(behaviour, delay), storage, settings)
def simple_request() -> ConvertRequest:
return ConvertRequest(source=Source(html="<html><body><p>ahoj</p></body></html>"))
async def test_successful_job_publishes_a_result(tmp_path) -> None:
manager = make_manager(tmp_path)
await manager.start()
try:
job = manager.submit(simple_request())
await asyncio.wait_for(job.done.wait(), timeout=5)
assert job.state.status == "done"
assert job.state.page_count == 1
assert (Path(tmp_path) / job.id / "result.pdf").exists()
finally:
await manager.stop()
async def test_conversion_error_keeps_its_code(tmp_path) -> None:
manager = make_manager(tmp_path, behaviour="conversion_error")
await manager.start()
try:
job = manager.submit(simple_request())
await asyncio.wait_for(job.done.wait(), timeout=5)
assert job.state.status == "failed"
assert job.state.error is not None
assert job.state.error.error_code == "limit_exceeded"
finally:
await manager.stop()
async def test_unexpected_error_is_reported_not_swallowed(tmp_path) -> None:
manager = make_manager(tmp_path, behaviour="crash")
await manager.start()
try:
job = manager.submit(simple_request())
await asyncio.wait_for(job.done.wait(), timeout=5)
assert job.state.status == "failed"
assert job.state.error.error_code == "internal_error"
finally:
await manager.stop()
async def test_running_job_can_be_cancelled(tmp_path) -> None:
manager = make_manager(tmp_path, delay=5.0)
await manager.start()
try:
job = manager.submit(simple_request())
await asyncio.sleep(0.2)
manager.cancel(job.id)
await asyncio.wait_for(job.done.wait(), timeout=5)
assert job.state.status == "cancelled"
assert not (Path(tmp_path) / job.id).exists()
finally:
await manager.stop()
async def test_full_queue_is_rejected(tmp_path) -> None:
manager = make_manager(tmp_path, delay=5.0, queue_max_size=1, workers=1)
await manager.start()
try:
manager.submit(simple_request())
await asyncio.sleep(0.1)
manager.submit(simple_request())
with pytest.raises(QueueFullError):
manager.submit(simple_request())
finally:
await manager.stop()
async def test_shutdown_marks_pending_jobs_as_failed(tmp_path) -> None:
manager = make_manager(tmp_path, delay=5.0, workers=1)
await manager.start()
job = manager.submit(simple_request())
await asyncio.sleep(0.1)
await manager.stop()
assert job.state.status == "failed"
assert job.state.error.error_code == "service_restarted"