Files
pigway-frame-flow/tests/test_source_libraries.py
T
渡口浪人 1494904b12
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 8m9s
fix: close library upload flow and harden image storage
2026-10-24 00:38:00 +08:00

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") == []