feat: organizer Android app and token-gated upload API
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:
Frank Schwenk
2026-08-27 21:38:50 +02:00
parent 5dcc3717c7
commit c03f17488c
58 changed files with 3134 additions and 186 deletions
+135
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
"""Tests for job helpers (labels, inbox names, payloads)."""
from __future__ import annotations
from pathlib import Path
import pytest
from app import jobs, pipeline
def test_status_label_known_and_passthrough() -> None:
assert jobs.status_label("done") == "Fertig"
assert jobs.status_label("queued") == "Warteschlange"
assert jobs.status_label("weird") == "weird"
def test_stage_label_variant_progress() -> None:
label = jobs.stage_label("processing", "variant", {"current": 1, "total": 3})
assert label == "Variante 1/3"
assert jobs.stage_label("queued", None, None) == "Wartet auf Worker"
assert jobs.stage_label("processing", "rembg", None) == "Freistellen"
def test_make_inbox_filename_unique_and_safe() -> None:
a = jobs.make_inbox_filename("party.jpg")
b = jobs.make_inbox_filename("party.jpg")
assert a != b
assert a.endswith("_party.jpg")
assert "/" not in a
jpeg = jobs.make_inbox_filename("IMG_0001.JPEG")
assert jpeg.lower().endswith(".jpg")
fallback = jobs.make_inbox_filename("noext")
assert fallback.endswith("_noext.jpg")
def test_validate_job_id_rejects_paths() -> None:
with pytest.raises(jobs.JobIdError):
jobs.validate_job_id("../evil")
assert jobs.validate_job_id("foo@bar") == "foo_bar"
def test_summarize_job_includes_labels(data_dir: Path) -> None:
paths = pipeline.job_paths("event01")
pipeline.write_status(
paths,
"processing",
stage="rembg",
progress={"current": 0, "total": 3},
created_at="2026-01-01T00:00:00",
)
summary = jobs.summarize_job("event01")
assert summary["status_label"] == "Läuft"
assert summary["stage_label"] == "Freistellen"
assert summary["variant_count"] == 0
+4 -1
View File
@@ -93,7 +93,10 @@ def test_job_file_srcset_resize(client: TestClient, data_dir: Path) -> None:
assert index.status_code == 200
assert f"/jobs/{stem}/files/{png.name}?w=640" in index.text
assert "srcset=" in index.text
assert "data-full-src=" in index.text
detail = client.get(f"/jobs/{stem}")
assert detail.status_code == 200
assert "data-full-src=" in detail.text
def test_img_attrs_builds_srcset() -> None: