import hashlib from datetime import UTC, datetime from io import BytesIO import pytest from PIL import Image from pigway_frame_flow.providers.photo_sources import PhotoSourceInput @pytest.fixture def library_source(service, settings, legacy_photo_engine): root = settings.data_dir.parent / "photos" root.mkdir() settings.photo_root = root Image.new("RGB", (900, 600), (25, 100, 220)).save(root / "photo.jpg") for source_id in ["family", "travel"]: service.photo_sources.save( source_id, PhotoSourceInput(name=source_id, kind="local", location=".") ) return "family" def create_plan( client, source="family", name="Watch", width=410, height=502, **changes ): response = client.post( "/api/v1/generation-plans", json={ "source_id": source, "name": name, "width": width, "height": height, **changes, }, ) assert response.status_code == 200, response.text return response.json() def bind(client, terminal, source, plan): response = client.put( "/api/v1/library-terminals/" + terminal + "/binding", json={"source_id": source, "plan_id": plan["id"]}, ) assert response.status_code == 200, response.text def import_picture(client): response = client.post( "/api/v1/source-libraries/family/import", json={"item_id": "photo.jpg"} ) assert response.status_code == 200, response.text return response.json() def test_single_binding_and_cross_library_guards(client, service, library_source): first = create_plan(client) other = create_plan(client, "travel") bind(client, "watch", "family", first) assert ( client.put( "/api/v1/library-terminals/watch/binding", json={"source_id": "travel", "plan_id": first["id"]}, ).status_code == 409 ) bind(client, "watch", "travel", other) assert service.db.one("SELECT COUNT(*) AS n FROM terminal_bindings")["n"] == 1 assert client.delete("/api/v1/generation-plans/" + other["id"]).status_code == 409 assert client.delete("/api/v1/photo-sources/travel").status_code == 409 assert ( client.put( "/api/v1/library-terminals/living-room/binding", json={"source_id": "family", "plan_id": first["id"]}, ).status_code == 422 ) assert ( client.put( "/api/v1/generation-plans/" + other["id"], json={ k: v for k, v in {**other, "width": 400}.items() if k not in ("id", "archived") }, ).status_code == 422 ) assert ( client.put( "/api/v1/generation-plans/" + first["id"], json={"name": "Move", "source_id": "travel"}, ).status_code == 409 ) def test_original_and_versions_share_picture_with_immutable_snapshots( client, service, library_source ): picture = import_picture(client) assert import_picture(client)["id"] == picture["id"] first = create_plan(client) create_plan(client, name="Frame", width=1200, height=1600) versions = client.post( "/api/v1/source-images/" + picture["id"] + "/generate", json={} ).json() assert len(versions) == 2 assert all(v["source"] == "generated" for v in versions) assert all(picture["name"].removesuffix(".jpg") in v["name"] for v in versions) assert len({v["source_image_id"] for v in versions}) == 1 repeated = client.post( "/api/v1/source-images/" + picture["id"] + "/generate", json={} ).json() assert {v["asset_id"] for v in versions} == {v["asset_id"] for v in repeated} payload = {k: v for k, v in first.items() if k not in ("id", "archived")} assert ( client.put( "/api/v1/generation-plans/" + first["id"], json={**payload, "quality": 70} ).status_code == 200 ) updated = client.post( "/api/v1/source-images/" + picture["id"] + "/generate", json={"plan_id": first["id"]}, ).json()[0] assert updated["asset_id"] not in {v["asset_id"] for v in versions} assert updated["plan"]["quality"] == 70 assert ( next(v for v in versions if v["plan_id"] == first["id"])["plan"]["quality"] == 85 ) other = create_plan(client, "travel") assert ( client.post( "/api/v1/source-images/" + picture["id"] + "/generate", json={"plan_id": other["id"]}, ).status_code == 409 ) assert client.get("/api/v1/assets").json() == [] assert client.delete("/api/v1/assets/" + picture["id"]).status_code == 409 assert ( client.post( "/api/v1/devices/living-room/display", json={"content": {"type": "image", "asset_id": picture["id"]}}, ).status_code == 409 ) assert ( client.put( "/api/v1/source-images/" + picture["id"] + "/star", json={"starred": True} ).status_code == 200 ) assert ( client.put( "/api/v1/source-images/" + picture["id"] + "/exclusion", json={"excluded": True}, ).status_code == 409 ) assert client.delete("/api/v1/source-images/" + picture["id"]).status_code == 204 assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 0 assert (service.settings.photo_root / "photo.jpg").exists() def test_pipeline_selects_once_for_frame_and_watch_and_shared_plan( client, service, library_source, monkeypatch ): frame = create_plan(client, name="Frame", width=1200, height=1600, caption=True) watch = create_plan(client) service.registry.drivers["second-frame"] = service.registry.get("living-room") for terminal in ["living-room", "second-frame"]: bind(client, terminal, "family", frame) bind(client, "watch", "family", watch) def unavailable_frame(*args, **kwargs): raise AssertionError("Source generation must not read a frame device") monkeypatch.setattr( service.registry.get("living-room"), "device", unavailable_frame ) read = service.photo_sources.read calls = [] def source_read(*args, **kwargs): calls.append(1) data, name, _ = read(*args, **kwargs) return data, name, {"title": "Caption", "date": "2026-10-23"} monkeypatch.setattr(service.photo_sources, "read", source_read) result = client.post("/api/v1/source-libraries/family/run", json={}).json() assert result["errors"] == [] assert len(calls) == 1 assert len(result["outputs"]) == 3 assert len({v["asset_id"] for v in result["outputs"]}) == 2 for terminal in ["living-room", "watch"]: page = client.get("/api/v1/library-terminals/" + terminal + "/images").json() assert page["total"] == 1 version = page["items"][0] with Image.open(BytesIO(client.get(version["url"]).content)) as image: if terminal == "watch": assert image.size == (410, 502) assert image.getpixel((200, 490))[2] > 200 else: assert image.getpixel((10, 1590))[0] > 240 assert client.post( "/api/v1/library-terminals/watch/images/" + result["outputs"][-1]["asset_id"] + "/send" ).status_code in (409, 422) assert service.db.one("SELECT COUNT(*) AS n FROM operations")["n"] == 0 def test_scheduled_pipeline_links_independent_delivery_and_rechecks_binding( client, app, service, library_source, monkeypatch ): frame = create_plan(client, name="Frame", width=1200, height=1600) watch = create_plan(client) bind(client, "living-room", "family", frame) bind(client, "watch", "family", watch) saved = client.post( "/api/v1/jobs", json={ "name": "Shared source", "schedule": {"type": "daily", "time": "07:00"}, "source": {"type": "photo_source", "source_id": "family"}, "library_pipeline": True, }, ).json() now = datetime.now(UTC) service.db.execute( "UPDATE jobs SET next_run=? WHERE id=?", (now.isoformat(), saved["id"]) ) app.state.scheduler.tick(now) root = service.db.one("SELECT id FROM operations WHERE action='library-generate'") assert root and service.process_one() op = client.get("/api/v1/operations/" + root["id"]).json() assert op["status"] == "COMPLETED" assert len(op["result"]["outputs"]) == 2 child = service.db.one( "SELECT id FROM operations WHERE client_id='library-terminal'" ) assert child assert ( service.db.one("SELECT parent_operation_id FROM terminal_deliveries")[ "parent_operation_id" ] == root["id"] ) client.delete("/api/v1/library-terminals/living-room/binding") writes = [] monkeypatch.setattr( service.registry.get("living-room"), "upload", lambda *args: writes.append(args) ) assert service.process_one() assert service.operation(child["id"])["status"] == "FAILED" assert writes == [] assert client.get("/api/v1/library-terminals/watch/images").json()["total"] == 1 def test_preview_is_unsaved_and_references_prevent_deletion( client, service, library_source ): picture = import_picture(client) plan = create_plan(client, name="Frame", width=1200, height=1600) payload = {k: v for k, v in plan.items() if k not in ("id", "archived")} assert ( client.post( "/api/v1/source-images/" + picture["id"] + "/preview", json=payload ).status_code == 200 ) assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 0 bind(client, "living-room", "family", plan) version = client.post( "/api/v1/source-images/" + picture["id"] + "/generate", json={"plan_id": plan["id"]}, ).json()[0] assert ( client.post( "/api/v1/library-terminals/living-room/images/" + version["asset_id"] + "/send" ).status_code == 200 ) assert client.delete("/api/v1/source-images/" + picture["id"]).status_code == 409 def test_resumed_source_run_reuses_saved_picture_plans_and_delivery( client, service, library_source, monkeypatch ): frame = create_plan(client, name="Frame", width=1200, height=1600) bind(client, "living-room", "family", frame) root = service.submit( "library-generate", {"content": {"type": "photo_source", "source_id": "family"}, "options": {}}, "living-room", "scheduler", "resume-test", ) assert service.process_one() previous = service.operation(root)["result"] child = previous["outputs"][0]["operation_id"] service.db.execute("UPDATE operations SET status='COMPLETED' WHERE id=?", (child,)) update = {k: v for k, v in frame.items() if k not in ("id", "archived")} assert ( client.put( "/api/v1/generation-plans/" + frame["id"], json={**update, "quality": 60} ).status_code == 200 ) service.db.execute("UPDATE operations SET status='QUEUED' WHERE id=?", (root,)) def unexpected_read(*args, **kwargs): raise AssertionError("Resumed run must not select a new photo") monkeypatch.setattr(service.photo_sources, "read", unexpected_read) service.settings.mock = False service.settings.write_enabled = False assert service.process_one() current = service.operation(root)["result"] assert current["source_image_id"] == previous["source_image_id"] assert current["outputs"][0]["operation_id"] == child assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1 assert ( service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='display'")[ "n" ] == 1 ) def test_one_plan_failure_preserves_other_output_and_source( client, service, library_source, monkeypatch ): from pigway_frame_flow.errors import ErrorCode, FlowError frame = create_plan(client, name="Frame", width=1200, height=1600) watch = create_plan(client) bind(client, "living-room", "family", frame) bind(client, "watch", "family", watch) generate = service.libraries.generate def fail_frame(picture_id, plan_id, *args, **kwargs): if plan_id == frame["id"]: raise FlowError(ErrorCode.INVALID_REQUEST, "Frame render failed") return generate(picture_id, plan_id, *args, **kwargs) monkeypatch.setattr(service.libraries, "generate", fail_frame) root = service.submit( "library-generate", {"content": {"type": "photo_source", "source_id": "family"}, "options": {}}, "living-room", "scheduler", "partial-test", ) assert service.process_one() op = client.get("/api/v1/operations/" + root).json() assert op["status"] == "FAILED" assert len(op["result"]["errors"]) == 1 and len(op["result"]["outputs"]) == 1 assert op["result"]["outputs"][0]["terminal_id"] == "watch" picture_id = op["result"]["source_image_id"] monkeypatch.setattr(service.libraries, "generate", generate) assert ( client.post( "/api/v1/source-images/" + picture_id + "/generate", json={"plan_id": frame["id"]}, ).status_code == 200 ) assert service.db.one("SELECT COUNT(*) AS n FROM source_images")["n"] == 1 def test_successful_scheduled_frame_delivery_retains_job_provenance( client, app, service, library_source ): frame = create_plan(client, name="Frame", width=1200, height=1600) bind(client, "living-room", "family", frame) saved = client.post( "/api/v1/jobs", json={ "name": "Library job", "schedule": {"type": "daily", "time": "07:00"}, "source": {"type": "photo_source", "source_id": "family"}, "library_pipeline": True, }, ).json() now = datetime.now(UTC) service.db.execute( "UPDATE jobs SET next_run=? WHERE id=?", (now.isoformat(), saved["id"]) ) app.state.scheduler.tick(now) assert service.process_one() and service.process_one() assert ( service.db.one("SELECT job_id FROM managed_cloud_uploads")["job_id"] == saved["id"] ) service.sync() page = client.get("/api/v1/library-terminals/living-room/images").json() assert page["items"][0]["delivery_status"] == "COMPLETED" from pigway_frame_flow.storage.database import encode, now_iso token = "library-reader-test" service.db.execute( "INSERT INTO clients VALUES(?,?,?,?,?,?,?)", ( "reader", hashlib.sha256(token.encode()).hexdigest(), "Reader", encode(["images:read", "devices:read"]), now_iso(), None, 1, ), ) public = client.get( "/api/v1/library-terminals/living-room/images", headers={"Authorization": "Bearer " + token}, ) assert public.status_code == 200 summary = public.json()["items"][0]["operation"] assert ( "request_id" not in summary and "client_id" not in summary and "error" not in summary ) assert ( client.get( "/api/v1/operations/" + summary["operation_id"], headers={"Authorization": "Bearer " + token}, ).status_code == 403 ) def test_caption_plan_respects_full_picture_fit(client, service, library_source): picture = import_picture(client) service.db.execute( "UPDATE source_images SET caption=? WHERE id=?", ('{"title":"Caption"}', picture["id"]), ) payload = { "source_id": "family", "name": "Caption", "width": 300, "height": 400, "caption": True, "fit": "contain", } response = client.post( "/api/v1/source-images/" + picture["id"] + "/preview", json=payload ) assert response.status_code == 200 with Image.open(BytesIO(response.content)) as image: assert image.getpixel((150, 0))[0] > 240 response = client.post( "/api/v1/source-images/" + picture["id"] + "/preview", json={**payload, "fit": "cover"}, ) with Image.open(BytesIO(response.content)) as image: assert image.getpixel((150, 0))[0] < 60 def test_shared_version_keeps_separate_delivery_receipts_for_each_job( client, app, service, library_source ): frame = create_plan(client, name="Frame", width=1200, height=1600) bind(client, "living-room", "family", frame) job_ids = [] for name in ["Morning", "Evening"]: job = client.post( "/api/v1/jobs", json={ "name": name, "schedule": {"type": "daily", "time": "07:00"}, "source": {"type": "photo_source", "source_id": "family"}, "library_pipeline": True, }, ).json() job_ids.append(job["id"]) now = datetime.now(UTC) service.db.execute("UPDATE jobs SET next_run=?", (now.isoformat(),)) app.state.scheduler.tick(now) assert service.process_one() and service.process_one() assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1 assert service.db.one("SELECT COUNT(*) AS n FROM terminal_deliveries")["n"] == 2 assert service.process_one() service.sync() assert service.process_one() service.sync() assert { r["job_id"] for r in service.db.all("SELECT job_id FROM managed_cloud_uploads") } == set(job_ids) page = client.get("/api/v1/library-terminals/living-room/images").json() assert page["total"] == 1 and page["items"][0]["delivery_status"] == "COMPLETED" def test_watch_only_source_task_does_not_require_a_frame_driver( client, app, service, library_source ): plan = create_plan(client) bind(client, "watch", "family", plan) service.registry.drivers.clear() response = client.post( "/api/v1/jobs", json={ "name": "Watch only", "device_id": "watch", "schedule": {"type": "daily", "time": "07:00"}, "source": {"type": "photo_source", "source_id": "family"}, "library_pipeline": True, }, ) assert response.status_code == 201 now = datetime.now(UTC) service.db.execute("UPDATE jobs SET next_run=?", (now.isoformat(),)) app.state.scheduler.tick(now) assert service.process_one() operation = service.db.one( "SELECT id,device_id,status FROM operations WHERE action='library-generate'" ) assert ( operation["device_id"] == "source:family" and operation["status"] == "COMPLETED" ) assert ( service.operation(operation["id"])["result"]["outputs"][0]["terminal_id"] == "watch" ) def test_interrupted_delivery_link_reuses_queue_idempotency( client, service, library_source ): plan = create_plan(client, name="Frame", width=1200, height=1600) bind(client, "living-room", "family", plan) root = service.submit( "library-generate", {"content": {"type": "photo_source", "source_id": "family"}, "options": {}}, "source:family", "scheduler", "receipt-gap", ) assert service.process_one() old = service.operation(root)["result"] child = old["outputs"][0]["operation_id"] service.db.execute("DELETE FROM terminal_deliveries WHERE operation_id=?", (child,)) service.db.execute("UPDATE operations SET status='QUEUED' WHERE id=?", (root,)) assert service.process_one() assert service.operation(root)["result"]["outputs"][0]["operation_id"] == child assert ( service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='display'")[ "n" ] == 1 ) assert ( service.db.one("SELECT operation_id FROM terminal_deliveries")["operation_id"] == child ) def test_mode_switch_preserves_existing_schedule(client, service, library_source): plan = create_plan(client) bind(client, "watch", "family", plan) original = client.post( "/api/v1/jobs", json={ "name": "Preserve timing", "schedule": {"type": "interval", "interval_minutes": 120}, "source": {"type": "photo_source", "source_id": "family"}, }, ).json() switched = client.put( "/api/v1/jobs/" + original["id"] + "/library-mode", json={"enabled": True} ) assert switched.status_code == 200 current = switched.json() assert current["library_pipeline"] is True assert ( current["next_run"] == original["next_run"] and current["schedule"] == original["schedule"] and current["enabled"] == original["enabled"] ) assert ( client.put( "/api/v1/jobs/" + original["id"] + "/library-mode", json={"enabled": False} ).status_code == 200 ) def test_single_source_migration_preserves_timing_and_generates_without_device_writes( client, service, library_source, monkeypatch ): import runpy from pathlib import Path migrate = runpy.run_path( str(Path(__file__).parents[1] / "scripts/migrate-source-libraries.py") )["migrate"] job = client.post( "/api/v1/jobs", json={ "name": "Existing source task", "schedule": {"type": "interval", "interval_minutes": 120}, "source": {"type": "photo_source", "source_id": "family"}, }, ).json() def call(method, path, payload=None): response = client.request(method, path, json=payload) response.raise_for_status() return None if response.status_code == 204 else response.json() with pytest.raises(RuntimeError): migrate(call, apply=True) assert client.delete("/api/v1/photo-sources/travel").status_code == 204 source_op = service.submit( "display", { "content": {"type": "photo_source", "source_id": "family"}, "options": {"priority": 0}, }, "living-room", "scheduler", "current-photo", ) assert service.process_one() service.sync() assert service.operation(source_op)["status"] == "COMPLETED" writes = [] monkeypatch.setattr( service.registry.get("living-room"), "upload", lambda *args: writes.append(args) ) result = migrate(call, apply=True) assert result["migrated"] and result["current_source_generated_locally"] assert writes == [] migrated = client.get("/api/v1/jobs").json()[0] assert ( migrated["library_pipeline"] is True and migrated["next_run"] == job["next_run"] ) assert service.db.one("SELECT COUNT(*) AS n FROM terminal_bindings")["n"] == 2 assert service.db.one("SELECT COUNT(*) AS n FROM source_images")["n"] == 1 assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 2 assert migrate(call, apply=True)["already_configured"]