956 lines
35 KiB
Python
956 lines
35 KiB
Python
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")
|
|
|
|
original_device = service.registry.get("living-room").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
|
|
monkeypatch.setattr(service.registry.get("living-room"), "device", original_device)
|
|
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"
|
|
assert page["items"][0]["current"] is True
|
|
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"]
|
|
|
|
|
|
def test_terminal_current_picture_uses_synced_live_asset(
|
|
client, service, library_source, monkeypatch
|
|
):
|
|
from pigway_frame_flow.errors import ErrorCode, FlowError
|
|
|
|
plan = create_plan(client, name="Frame", width=1200, height=1600)
|
|
bind(client, "living-room", "family", plan)
|
|
picture = import_picture(client)
|
|
version = client.post(
|
|
"/api/v1/source-images/" + picture["id"] + "/generate", json={}
|
|
).json()[0]
|
|
current = {"synced": True, "source_known": True, "asset_id": version["asset_id"]}
|
|
monkeypatch.setattr(service, "current_content", lambda _, **kwargs: current)
|
|
endpoint = "/api/v1/library-terminals/living-room/images"
|
|
assert client.get(endpoint).json()["items"][0]["current"] is True
|
|
current["asset_id"] = "another-picture"
|
|
assert client.get(endpoint).json()["items"][0]["current"] is False
|
|
current.update(asset_id=version["asset_id"], synced=False)
|
|
assert client.get(endpoint).json()["items"][0]["current"] is False
|
|
current.update(synced=True, source_known=False)
|
|
assert client.get(endpoint).json()["items"][0]["current"] is False
|
|
|
|
def offline(_, **kwargs):
|
|
raise FlowError(ErrorCode.CLOUD_UNAVAILABLE, "Offline", 502)
|
|
|
|
monkeypatch.setattr(service, "current_content", offline)
|
|
page = client.get(endpoint)
|
|
assert page.status_code == 200 and page.json()["items"][0]["current"] is False
|
|
|
|
driver = service.registry.get("living-room")
|
|
offline_device = driver.device().model_copy(update={"status": "offline"})
|
|
monkeypatch.setattr(driver, "device", lambda: offline_device)
|
|
monkeypatch.setattr(
|
|
service,
|
|
"current_content",
|
|
lambda _, **kwargs: pytest.fail(
|
|
"Offline state must not be treated as a display confirmation"
|
|
),
|
|
)
|
|
assert client.get(endpoint).json()["items"][0]["current"] is False
|
|
|
|
|
|
def test_plan_rename_uses_current_labels_without_rerendering_legacy_versions(
|
|
client, service, library_source
|
|
):
|
|
import json
|
|
|
|
from pigway_frame_flow.storage.database import encode
|
|
|
|
plan = create_plan(client, name="Original", width=1200, height=1600)
|
|
bind(client, "living-room", "family", plan)
|
|
picture = import_picture(client)
|
|
endpoint = "/api/v1/source-images/" + picture["id"] + "/generate"
|
|
original = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
|
|
snapshot = original["plan"]
|
|
original_bytes = service.assets.path(original["asset_id"]).read_bytes()
|
|
# Existing deployed versions used the entire plan, including its old name.
|
|
legacy_revision = hashlib.sha256(encode(snapshot).encode()).hexdigest()
|
|
service.db.execute(
|
|
"UPDATE library_versions SET revision=? WHERE asset_id=?",
|
|
(legacy_revision, original["asset_id"]),
|
|
)
|
|
provenance = json.loads(
|
|
service.db.one(
|
|
"SELECT payload FROM asset_sources WHERE asset_id=?",
|
|
(original["asset_id"],),
|
|
)["payload"]
|
|
)
|
|
provenance["revision"] = legacy_revision
|
|
service.db.execute(
|
|
"UPDATE asset_sources SET payload=? WHERE asset_id=?",
|
|
(encode(provenance), original["asset_id"]),
|
|
)
|
|
payload = {k: v for k, v in plan.items() if k not in ("id", "archived")}
|
|
renamed = {**payload, "name": "Frame v2.1"}
|
|
assert (
|
|
client.put("/api/v1/generation-plans/" + plan["id"], json=renamed).status_code
|
|
== 200
|
|
)
|
|
repeated = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
|
|
assert repeated["asset_id"] == original["asset_id"]
|
|
assert repeated["revision"] == legacy_revision
|
|
assert repeated["plan"] == snapshot
|
|
assert repeated["plan_name"] == "Frame v2.1"
|
|
assert repeated["name"] == "photo-Frame v2.1.jpg"
|
|
assert repeated["stored_name"] == original["stored_name"]
|
|
assert repeated["matches_current_plan"] is True
|
|
assert service.assets.path(original["asset_id"]).read_bytes() == original_bytes
|
|
assert service.db.one("SELECT COUNT(*) AS n FROM library_versions")["n"] == 1
|
|
source_version = client.get("/api/v1/source-libraries/family/images").json()[
|
|
"items"
|
|
][0]["versions"][0]
|
|
terminal_version = client.get(
|
|
"/api/v1/library-terminals/living-room/images"
|
|
).json()["items"][0]
|
|
assert source_version["plan_name"] == terminal_version["plan_name"] == "Frame v2.1"
|
|
assert source_version["name"] == terminal_version["name"]
|
|
assert (
|
|
client.put(
|
|
"/api/v1/generation-plans/" + plan["id"], json={**renamed, "quality": 70}
|
|
).status_code
|
|
== 200
|
|
)
|
|
old = client.get("/api/v1/library-terminals/living-room/images").json()["items"][0]
|
|
assert old["matches_current_plan"] is False and old["plan"]["quality"] == 85
|
|
updated = client.post(endpoint, json={"plan_id": plan["id"]}).json()[0]
|
|
assert (
|
|
updated["asset_id"] != original["asset_id"]
|
|
and updated["matches_current_plan"] is True
|
|
)
|
|
assert updated["plan"]["quality"] == 70
|
|
client.delete("/api/v1/library-terminals/living-room/binding")
|
|
assert client.delete("/api/v1/generation-plans/" + plan["id"]).status_code == 204
|
|
versions = client.get("/api/v1/source-libraries/family/images").json()["items"][0][
|
|
"versions"
|
|
]
|
|
assert len(versions) == 2 and all(v["plan_archived"] for v in versions)
|
|
|
|
|
|
def test_source_deletion_removes_unreferenced_archived_plans(
|
|
client, service, library_source
|
|
):
|
|
plan = create_plan(client)
|
|
assert client.delete("/api/v1/generation-plans/" + plan["id"]).status_code == 204
|
|
assert client.delete("/api/v1/photo-sources/family").status_code == 204
|
|
assert (
|
|
service.db.one("SELECT id FROM library_plans WHERE source_id='family'") is None
|
|
)
|
|
assert client.get("/api/v1/generation-plans?source_id=family").status_code == 404
|
|
|
|
|
|
def test_manual_upload_survives_reload_and_generates_bound_versions(client, service):
|
|
data = BytesIO()
|
|
Image.new("RGB", (700, 500), "blue").save(data, "PNG")
|
|
path = "/api/v1/source-libraries/manual-uploads/upload"
|
|
result = client.post(
|
|
path, files={"file": ("original.png", data.getvalue(), "image/png")}
|
|
)
|
|
assert result.status_code == 200, result.text
|
|
picture = result.json()
|
|
with Image.open(service.assets.path(picture["id"])) as image:
|
|
assert image.size == (700, 500)
|
|
assert (
|
|
client.post(path, files={"file": ("same.png", data.getvalue())}).json()["id"]
|
|
== picture["id"]
|
|
)
|
|
listed = client.get("/api/v1/source-libraries/manual-uploads/images").json()
|
|
assert listed["total"] == 1 and listed["items"][0]["id"] == picture["id"]
|
|
plan = create_plan(client, "manual-uploads")
|
|
bind(client, "watch", "manual-uploads", plan)
|
|
generated = client.post(
|
|
"/api/v1/source-images/" + picture["id"] + "/generate",
|
|
json={"plan_id": plan["id"]},
|
|
)
|
|
assert generated.status_code == 200, generated.text
|
|
assert generated.json()[0]["plan"]["width"] == 410
|
|
run = client.post(
|
|
"/api/v1/source-libraries/manual-uploads/run", json={"deliver": False}
|
|
)
|
|
assert run.status_code == 200, run.text
|
|
assert (
|
|
client.get("/api/v1/source-libraries/manual-uploads/images").json()["total"]
|
|
== 1
|
|
)
|
|
assert client.get("/api/v1/library-terminals/watch/images").json()["total"] == 1
|
|
|
|
|
|
def test_invalid_upload_does_not_create_source(client, service):
|
|
response = client.post(
|
|
"/api/v1/source-libraries/manual-uploads/upload",
|
|
files={"file": ("bad.jpg", b"not an image")},
|
|
)
|
|
assert response.status_code == 400
|
|
assert service.photo_sources.list() == []
|
|
|
|
|
|
def test_ingest_sql_failure_rolls_back_asset_and_file(
|
|
client, service, library_source, monkeypatch
|
|
):
|
|
original = service.db.execute
|
|
|
|
def fail(query, args=()):
|
|
if "INSERT INTO source_images" in query:
|
|
raise RuntimeError("injected SQL failure")
|
|
return original(query, args)
|
|
|
|
monkeypatch.setattr(service.db, "execute", fail)
|
|
with pytest.raises(RuntimeError):
|
|
service.libraries.import_picture("family", "photo.jpg")
|
|
assert service.db.all("SELECT * FROM assets") == []
|
|
assert service.db.all("SELECT * FROM source_images") == []
|
|
assert list((service.settings.data_dir / "source-images").iterdir()) == []
|
|
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
|
|
|
|
|
def test_generation_sql_failure_keeps_source_without_orphan(
|
|
client, service, library_source, monkeypatch
|
|
):
|
|
picture = import_picture(client)
|
|
plan = create_plan(client)
|
|
original = service.db.execute
|
|
|
|
def fail(query, args=()):
|
|
if "INSERT INTO library_versions" in query:
|
|
raise RuntimeError("injected SQL failure")
|
|
return original(query, args)
|
|
|
|
monkeypatch.setattr(service.db, "execute", fail)
|
|
with pytest.raises(RuntimeError):
|
|
service.libraries.generate(picture["id"], plan["id"])
|
|
assert len(service.db.all("SELECT * FROM assets")) == 1
|
|
assert list((service.settings.data_dir / "renditions").iterdir()) == []
|
|
assert service.assets.path(picture["id"]).exists()
|
|
|
|
|
|
def test_delete_sql_failure_preserves_files_and_relations(
|
|
client, service, library_source, monkeypatch
|
|
):
|
|
picture = import_picture(client)
|
|
plan = create_plan(client)
|
|
version = service.libraries.generate(picture["id"], plan["id"])
|
|
original = service.db.execute
|
|
|
|
def fail(query, args=()):
|
|
if query == "DELETE FROM source_images WHERE id=?":
|
|
raise RuntimeError("injected SQL failure")
|
|
return original(query, args)
|
|
|
|
monkeypatch.setattr(service.db, "execute", fail)
|
|
with pytest.raises(RuntimeError):
|
|
service.libraries.delete_picture(picture["id"])
|
|
assert service.assets.path(version["asset_id"]).exists()
|
|
assert (
|
|
service.libraries.picture(picture["id"])["versions"][0]["asset_id"]
|
|
== version["asset_id"]
|
|
)
|
|
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
|
|
|
|
|
def test_delete_file_failure_retries_on_restart(
|
|
client, service, library_source, monkeypatch
|
|
):
|
|
from pathlib import Path
|
|
|
|
from pigway_frame_flow.renderers.images import Assets
|
|
|
|
picture = import_picture(client)
|
|
path = service.assets.path(picture["id"])
|
|
unlink = Path.unlink
|
|
|
|
def fail(file, *args, **kwargs):
|
|
if file == path:
|
|
raise OSError("injected unlink failure")
|
|
return unlink(file, *args, **kwargs)
|
|
|
|
with monkeypatch.context() as patch:
|
|
patch.setattr(Path, "unlink", fail)
|
|
service.libraries.delete_picture(picture["id"])
|
|
assert service.db.all("SELECT * FROM source_images") == []
|
|
assert (
|
|
service.db.one("SELECT id FROM asset_file_journal")["id"] == picture["id"]
|
|
)
|
|
Assets(service.db, service.settings)
|
|
assert not path.exists()
|
|
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
|
|
|
|
|
def test_asset_disk_write_failure_leaves_no_partial_file(service, monkeypatch):
|
|
import os
|
|
|
|
fsync = os.fsync
|
|
calls = 0
|
|
|
|
def fail(fd):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
raise OSError("injected disk write failure")
|
|
return fsync(fd)
|
|
|
|
monkeypatch.setattr(os, "fsync", fail)
|
|
with pytest.raises(OSError):
|
|
service.assets.put(b"data", "failed.jpg")
|
|
assert service.db.all("SELECT * FROM assets") == []
|
|
assert service.db.all("SELECT * FROM asset_file_journal") == []
|
|
assert list((service.settings.data_dir / "images").iterdir()) == []
|
|
|
|
|
|
def test_asset_startup_removes_interrupted_creation(service):
|
|
from pigway_frame_flow.renderers.images import Assets
|
|
|
|
asset_id = "c" * 32
|
|
root = service.settings.data_dir / "images"
|
|
(root / (asset_id + ".jpg")).write_bytes(b"incomplete")
|
|
(root / (asset_id + ".part")).write_bytes(b"partial")
|
|
service.db.execute(
|
|
"INSERT INTO asset_file_journal VALUES(?,?)", (asset_id, "images")
|
|
)
|
|
Assets(service.db, service.settings)
|
|
assert not list(root.iterdir())
|
|
assert service.db.all("SELECT * FROM asset_file_journal") == []
|