2026-10-06 09:10:36 +08:00
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
from pigway_frame_flow.errors import ErrorCode as E
|
|
|
|
|
from pigway_frame_flow.errors import FlowError
|
|
|
|
|
from pigway_frame_flow.models import JobInput, Schedule
|
|
|
|
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
|
|
|
|
from pigway_frame_flow.storage.database import encode
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upload_random(client, service, settings):
|
|
|
|
|
root = settings.data_dir.parent / "photos"
|
|
|
|
|
root.mkdir()
|
|
|
|
|
settings.photo_root = root
|
|
|
|
|
Image.new("RGB", (300, 200)).save(root / "a.jpg")
|
|
|
|
|
service.photo_sources.save(
|
|
|
|
|
"family", PhotoSourceInput(name="Family", kind="local", location=".")
|
|
|
|
|
)
|
|
|
|
|
r = client.post(
|
|
|
|
|
"/api/v1/display",
|
|
|
|
|
json={
|
|
|
|
|
"device_id": "living-room",
|
|
|
|
|
"content": {"type": "photo_source", "source_id": "family"},
|
|
|
|
|
"options": {"cleanup_unstarred": True},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 202
|
|
|
|
|
service.process_one()
|
|
|
|
|
op = client.get("/api/v1/operations/" + r.json()["operation_id"]).json()
|
|
|
|
|
return op["result"]["image_id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def due(service, image_id):
|
|
|
|
|
service.db.execute(
|
|
|
|
|
"UPDATE image_retention SET expires_at=? WHERE image_id=?",
|
|
|
|
|
((datetime.now(UTC) - timedelta(days=1)).isoformat(), image_id),
|
|
|
|
|
)
|
|
|
|
|
# The test driver switches to a different image, not a real device.
|
|
|
|
|
driver = service.registry.get("living-room")
|
|
|
|
|
driver.display(1)
|
|
|
|
|
service.db.execute(
|
|
|
|
|
"UPDATE device_state SET previous_image_id=NULL,base_image_id=NULL WHERE device_id=?",
|
|
|
|
|
("living-room",),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_register_new_random_upload_with_seven_day_expiry(client, service, settings):
|
|
|
|
|
image_id = upload_random(client, service, settings)
|
|
|
|
|
row = service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
|
|
|
|
|
delta = datetime.fromisoformat(row["expires_at"]) - datetime.now(UTC)
|
|
|
|
|
assert 6.99 < delta.total_seconds() / 86400 <= 7
|
|
|
|
|
assert row["upload_operation"] and not row["starred"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_star_api_and_gallery_persist(client, service, settings):
|
|
|
|
|
image_id = upload_random(client, service, settings)
|
|
|
|
|
r = client.put(
|
|
|
|
|
f"/api/v1/devices/living-room/images/{image_id}/star", json={"starred": True}
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200 and r.json()["starred"]
|
|
|
|
|
items = client.get("/api/v1/devices/living-room/images").json()
|
|
|
|
|
assert next(x for x in items if x["image_id"] == image_id)["starred"]
|
|
|
|
|
due(service, image_id)
|
|
|
|
|
service.retention.sweep()
|
|
|
|
|
assert (
|
|
|
|
|
service.db.one(
|
|
|
|
|
"SELECT cleanup_operation FROM image_retention WHERE image_id=?",
|
|
|
|
|
(image_id,),
|
|
|
|
|
)["cleanup_operation"]
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_due_cleanup_deletes_only_owned_upload(client, service, settings):
|
|
|
|
|
image_id = upload_random(client, service, settings)
|
|
|
|
|
due(service, image_id)
|
|
|
|
|
service.retention.sweep()
|
|
|
|
|
row = service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
|
|
|
|
|
assert row["cleanup_operation"]
|
|
|
|
|
service.process_one()
|
|
|
|
|
assert (
|
|
|
|
|
service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
assert 1 in {x.image_id for x in service.registry.get("living-room").images()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_current_and_restore_and_playlist_protected(client, service, settings):
|
|
|
|
|
image_id = upload_random(client, service, settings)
|
|
|
|
|
service.db.execute(
|
|
|
|
|
"UPDATE image_retention SET expires_at=? WHERE image_id=?",
|
|
|
|
|
("2000-01-01T00:00:00+00:00", image_id),
|
|
|
|
|
)
|
|
|
|
|
service.retention.sweep()
|
|
|
|
|
assert not service.db.one(
|
|
|
|
|
"SELECT cleanup_operation FROM image_retention WHERE image_id=?", (image_id,)
|
|
|
|
|
)["cleanup_operation"]
|
|
|
|
|
due(service, image_id)
|
|
|
|
|
service.db.execute("UPDATE device_state SET previous_image_id=?", (image_id,))
|
|
|
|
|
assert not service.retention.eligible("living-room", image_id)
|
|
|
|
|
service.db.execute("UPDATE device_state SET previous_image_id=NULL")
|
|
|
|
|
service.db.execute(
|
|
|
|
|
"INSERT INTO playlists VALUES(?,?,0,NULL)",
|
|
|
|
|
("p", encode({"device_id": "living-room", "image_ids": [image_id]})),
|
|
|
|
|
)
|
|
|
|
|
assert not service.retention.eligible("living-room", image_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_star_between_queue_and_execution_cancels_delete(client, service, settings):
|
|
|
|
|
image_id = upload_random(client, service, settings)
|
|
|
|
|
due(service, image_id)
|
|
|
|
|
service.retention.sweep()
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
f"/api/v1/devices/living-room/images/{image_id}/star",
|
|
|
|
|
json={"starred": True},
|
|
|
|
|
).status_code
|
|
|
|
|
== 200
|
|
|
|
|
)
|
|
|
|
|
service.process_one()
|
|
|
|
|
assert image_id in {
|
|
|
|
|
x.image_id for x in service.registry.get("living-room").images()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_delete_is_not_retried(client, service, settings, monkeypatch):
|
|
|
|
|
image_id = upload_random(client, service, settings)
|
|
|
|
|
due(service, image_id)
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def unknown(id):
|
|
|
|
|
calls.append(id)
|
|
|
|
|
raise FlowError(E.WRITE_RESULT_UNKNOWN, "Unknown delete result")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service.registry.get("living-room"), "delete", unknown)
|
|
|
|
|
service.retention.sweep()
|
|
|
|
|
service.process_one()
|
|
|
|
|
service.retention.checked = 0
|
|
|
|
|
service.retention.sweep()
|
|
|
|
|
assert calls == [image_id]
|
|
|
|
|
assert service.blocked("living-room")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cleanup_option_requires_random_photo_job():
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
JobInput(
|
|
|
|
|
name="x",
|
|
|
|
|
schedule=Schedule(type="daily", time="07:00"),
|
|
|
|
|
source={"type": "template", "template": "quote"},
|
|
|
|
|
cleanup_unstarred=True,
|
|
|
|
|
)
|
2026-10-10 01:35:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_first_cleanup_runs_after_recent_host_boot(
|
|
|
|
|
client, service, settings, monkeypatch
|
|
|
|
|
):
|
|
|
|
|
image_id = upload_random(client, service, settings)
|
|
|
|
|
due(service, image_id)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"pigway_frame_flow.display.retention.time.monotonic", lambda: 120
|
|
|
|
|
)
|
|
|
|
|
service.retention.sweep()
|
|
|
|
|
row = service.db.one("SELECT * FROM image_retention WHERE image_id=?", (image_id,))
|
|
|
|
|
assert row["cleanup_operation"]
|
|
|
|
|
assert service.retention.checked == 120
|
|
|
|
|
service.retention.sweep()
|
|
|
|
|
assert (
|
|
|
|
|
service.db.one("SELECT COUNT(*) AS n FROM operations WHERE action='delete'")[
|
|
|
|
|
"n"
|
|
|
|
|
]
|
|
|
|
|
== 1
|
|
|
|
|
)
|
2026-10-17 01:39:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# Legacy algorithms remain covered; production filesystem access is tested separately.
|
|
|
|
|
pytestmark = pytest.mark.usefixtures("legacy_photo_engine")
|