feat: organizer Android app and token-gated upload API
CI / lint-and-test (push) Failing after 1m18s
CI / lint-and-test (push) Failing after 1m18s
Replace Syncthing as the product upload path with a share-target app and POST /api/jobs. Web UI live-polls job progress. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""Tests for JSON API upload and job payloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from app import config, pipeline
|
||||
from app.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(data_dir: Path) -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upload_token(monkeypatch: pytest.MonkeyPatch) -> str:
|
||||
token = "test-upload-token"
|
||||
monkeypatch.setattr(config, "UPLOAD_TOKEN", token)
|
||||
return token
|
||||
|
||||
|
||||
def _jpeg_bytes(size: tuple[int, int] = (80, 60)) -> bytes:
|
||||
from io import BytesIO
|
||||
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", size, color=(12, 34, 56)).save(buf, format="JPEG", quality=85)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_health_reports_upload_disabled(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(config, "UPLOAD_TOKEN", "")
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True, "upload": False}
|
||||
|
||||
|
||||
def test_upload_disabled_without_token(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(config, "UPLOAD_TOKEN", "")
|
||||
response = client.post(
|
||||
"/api/jobs",
|
||||
files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")},
|
||||
)
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_upload_rejects_bad_token(client: TestClient, upload_token: str) -> None:
|
||||
response = client.post(
|
||||
"/api/jobs",
|
||||
files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")},
|
||||
headers={"Authorization": "Bearer nope"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_upload_accepts_bearer_and_queues(client: TestClient, upload_token: str, data_dir: Path) -> None:
|
||||
response = client.post(
|
||||
"/api/jobs",
|
||||
files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {upload_token}"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
body = response.json()
|
||||
assert body["status"] == "queued"
|
||||
job_id = body["job_id"]
|
||||
assert job_id.endswith("_party")
|
||||
inbox = list(config.INBOX_DIR.glob("*_party.jpg"))
|
||||
assert len(inbox) == 1
|
||||
assert not inbox[0].name.startswith(".")
|
||||
meta = pipeline.read_meta(job_id)
|
||||
assert meta is not None
|
||||
assert meta["status"] == "queued"
|
||||
|
||||
|
||||
def test_upload_accepts_x_upload_token_header(client: TestClient, upload_token: str) -> None:
|
||||
response = client.post(
|
||||
"/api/jobs",
|
||||
files={"file": ("cam.png", _jpeg_bytes(), "image/png")},
|
||||
headers={"X-Upload-Token": upload_token},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
assert response.json()["status"] == "queued"
|
||||
|
||||
|
||||
def test_upload_rejects_empty_file(client: TestClient, upload_token: str) -> None:
|
||||
response = client.post(
|
||||
"/api/jobs",
|
||||
files={"file": ("empty.jpg", b"", "image/jpeg")},
|
||||
headers={"Authorization": f"Bearer {upload_token}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_job_detail_and_list(client: TestClient, data_dir: Path) -> None:
|
||||
stem = "photo01"
|
||||
paths = pipeline.job_paths(stem)
|
||||
paths.variants.mkdir(parents=True, exist_ok=True)
|
||||
png = paths.variants / f"{stem}_v1.png"
|
||||
Image.new("RGB", (32, 32), color=(1, 2, 3)).save(png, format="PNG")
|
||||
pipeline.write_status(
|
||||
paths,
|
||||
"processing",
|
||||
stage="variant",
|
||||
progress={"current": 2, "total": 3},
|
||||
created_at="2026-01-01T00:00:00",
|
||||
)
|
||||
pipeline.write_manifest(
|
||||
paths,
|
||||
{
|
||||
"variants": [{"id": "v1", "file": png.name}],
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
listed = client.get("/api/jobs")
|
||||
assert listed.status_code == 200
|
||||
payload = listed.json()
|
||||
assert payload["active"] is True
|
||||
assert payload["jobs"][0]["job_id"] == stem
|
||||
assert payload["jobs"][0]["status_label"] == "Läuft"
|
||||
assert payload["jobs"][0]["stage_label"] == "Variante 2/3"
|
||||
|
||||
detail = client.get(f"/api/jobs/{stem}")
|
||||
assert detail.status_code == 200
|
||||
body = detail.json()
|
||||
assert body["has_rembg"] is False
|
||||
assert body["variants"][0]["url"].endswith(f"/jobs/{stem}/files/{png.name}")
|
||||
assert body["progress"] == {"current": 2, "total": 3}
|
||||
|
||||
|
||||
def test_api_job_missing(client: TestClient) -> None:
|
||||
assert client.get("/api/jobs/does-not-exist").status_code == 404
|
||||
Reference in New Issue
Block a user