2026-10-20 18:50:43 +08:00
|
|
|
import json
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
from pigway_frame_flow.core.photo_preferences import eligible, flags, set_flags
|
|
|
|
|
from pigway_frame_flow.errors import FlowError
|
|
|
|
|
from pigway_frame_flow.models import AutoCollageContent, PhotoSourceContent
|
|
|
|
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def photo(service, name, color):
|
|
|
|
|
data = BytesIO()
|
|
|
|
|
Image.new("RGB", (320, 240), color).save(data, "JPEG")
|
|
|
|
|
return service.assets.put(data.getvalue(), name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_local_exclusion_star_and_restore(client, service):
|
|
|
|
|
excluded = photo(service, "excluded.jpg", "red")["asset_id"]
|
|
|
|
|
allowed = photo(service, "allowed.jpg", "blue")["asset_id"]
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
f"/api/v1/assets/{excluded}/star", json={"starred": True}
|
|
|
|
|
).status_code
|
|
|
|
|
== 200
|
|
|
|
|
)
|
|
|
|
|
result = client.put(f"/api/v1/assets/{excluded}/exclusion", json={"excluded": True})
|
2026-10-20 19:19:30 +08:00
|
|
|
assert result.status_code == 409
|
|
|
|
|
client.put(f"/api/v1/assets/{excluded}/star", json={"starred": False})
|
|
|
|
|
result = client.put(f"/api/v1/assets/{excluded}/exclusion", json={"excluded": True})
|
2026-10-20 18:50:43 +08:00
|
|
|
assert (
|
|
|
|
|
result.status_code == 200
|
|
|
|
|
and result.json()["excluded"]
|
2026-10-20 19:19:30 +08:00
|
|
|
and not result.json()["starred"]
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
f"/api/v1/assets/{excluded}/star", json={"starred": True}
|
|
|
|
|
).status_code
|
|
|
|
|
== 409
|
|
|
|
|
)
|
|
|
|
|
batch = client.post(
|
|
|
|
|
"/api/v1/assets/batch", json={"asset_ids": [excluded], "action": "star"}
|
|
|
|
|
).json()
|
|
|
|
|
assert (
|
|
|
|
|
batch["results"][0]["status"] == "failed"
|
|
|
|
|
and batch["results"][0]["code"] == "CONFLICT"
|
2026-10-20 18:50:43 +08:00
|
|
|
)
|
|
|
|
|
assert next(
|
|
|
|
|
x for x in client.get("/api/v1/assets").json() if x["asset_id"] == excluded
|
|
|
|
|
)["excluded"]
|
|
|
|
|
asset = service.content_asset(
|
|
|
|
|
AutoCollageContent(type="auto_collage", count=1, use_ai=False)
|
|
|
|
|
)
|
|
|
|
|
saved = json.loads(
|
|
|
|
|
service.db.one(
|
|
|
|
|
"SELECT payload FROM asset_sources WHERE asset_id=?", (asset["asset_id"],)
|
|
|
|
|
)["payload"]
|
|
|
|
|
)
|
|
|
|
|
assert [r["asset_id"] for r in saved["data"]["layout"]["regions"]] == [allowed]
|
|
|
|
|
assert service.assets.path(excluded).is_file()
|
|
|
|
|
assert (
|
|
|
|
|
client.post(
|
|
|
|
|
"/api/v1/assets/batch", json={"asset_ids": [allowed], "action": "exclude"}
|
|
|
|
|
).json()["results"][0]["status"]
|
|
|
|
|
== "completed"
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
service.content_asset(
|
|
|
|
|
AutoCollageContent(type="auto_collage", count=1, use_ai=False)
|
|
|
|
|
)
|
|
|
|
|
restored = client.put(
|
|
|
|
|
f"/api/v1/assets/{excluded}/exclusion", json={"excluded": False}
|
|
|
|
|
).json()
|
2026-10-20 19:19:30 +08:00
|
|
|
assert not restored["excluded"] and not restored["starred"]
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
f"/api/v1/assets/{excluded}/star", json={"starred": True}
|
|
|
|
|
).status_code
|
|
|
|
|
== 200
|
|
|
|
|
)
|
2026-10-20 18:50:43 +08:00
|
|
|
assert service.content_asset(
|
|
|
|
|
AutoCollageContent(type="auto_collage", count=1, use_ai=False)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_source_preferences_are_scoped_and_do_not_mutate_cached_catalog(
|
|
|
|
|
client, service, monkeypatch
|
|
|
|
|
):
|
|
|
|
|
source = PhotoSourceInput(
|
|
|
|
|
name="Drive", kind="webdav", location="https://photos.example.test/dav"
|
|
|
|
|
)
|
|
|
|
|
items = [{"id": "folder/photo.jpg", "name": "photo.jpg"}]
|
|
|
|
|
monkeypatch.setattr(service.photo_sources, "get", lambda _: source)
|
|
|
|
|
monkeypatch.setattr(service.photo_sources, "catalog", lambda *args, **kwargs: items)
|
|
|
|
|
url = "/api/v1/photo-sources/drive/preferences"
|
|
|
|
|
assert (
|
|
|
|
|
client.put(url, json={"item_id": items[0]["id"], "starred": True}).status_code
|
|
|
|
|
== 200
|
|
|
|
|
)
|
2026-10-20 19:19:30 +08:00
|
|
|
assert (
|
|
|
|
|
client.put(url, json={"item_id": items[0]["id"], "excluded": True}).status_code
|
|
|
|
|
== 409
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
url, json={"item_id": items[0]["id"], "starred": True, "excluded": True}
|
|
|
|
|
).status_code
|
|
|
|
|
== 409
|
|
|
|
|
)
|
2026-10-20 18:50:43 +08:00
|
|
|
assert client.put(
|
2026-10-20 19:19:30 +08:00
|
|
|
url, json={"item_id": items[0]["id"], "starred": False, "excluded": True}
|
|
|
|
|
).json() == {"starred": False, "excluded": True}
|
2026-10-20 18:50:43 +08:00
|
|
|
result = client.get("/api/v1/photo-sources/drive/images").json()["items"][0]
|
2026-10-20 19:19:30 +08:00
|
|
|
assert not result["starred"] and result["excluded"]
|
2026-10-20 18:50:43 +08:00
|
|
|
assert "excluded" not in items[0]
|
|
|
|
|
assert eligible(service.db, "drive", items) == []
|
|
|
|
|
assert eligible(service.db, "another-drive", items) == items
|
|
|
|
|
assert (
|
|
|
|
|
client.put(url, json={"item_id": "missing", "excluded": True}).status_code
|
|
|
|
|
== 404
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
service.content_asset(
|
|
|
|
|
AutoCollageContent(
|
|
|
|
|
type="auto_collage", source_id="drive", count=1, use_ai=False
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
service.photo_sources.read("drive")
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
service.content_asset(
|
|
|
|
|
PhotoSourceContent(type="photo_source", source_id="drive")
|
|
|
|
|
)
|
|
|
|
|
client.put(url, json={"item_id": items[0]["id"], "excluded": False})
|
|
|
|
|
assert eligible(service.db, "drive", items) == items
|
|
|
|
|
assert flags(service.db, "drive", items[0]["id"]) == {
|
2026-10-20 19:19:30 +08:00
|
|
|
"starred": False,
|
2026-10-20 18:50:43 +08:00
|
|
|
"excluded": False,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_preference_persistence(settings):
|
|
|
|
|
from pigway_frame_flow.storage.database import Database
|
|
|
|
|
|
|
|
|
|
path = settings.data_dir / "preferences.db"
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
db = Database(path)
|
|
|
|
|
set_flags(db, "drive", "item", excluded=True)
|
|
|
|
|
db.conn.close()
|
|
|
|
|
reopened = Database(path)
|
|
|
|
|
assert flags(reopened, "drive", "item")["excluded"]
|
|
|
|
|
reopened.conn.close()
|
2026-10-20 19:19:30 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["true", "false", 1, 0, [], {}])
|
|
|
|
|
def test_marks_reject_non_boolean_payloads(client, service, value):
|
|
|
|
|
key = photo(service, "fixture.jpg", "red")["asset_id"]
|
|
|
|
|
assert (
|
|
|
|
|
client.put(f"/api/v1/assets/{key}/star", json={"starred": value}).status_code
|
|
|
|
|
== 422
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
f"/api/v1/assets/{key}/exclusion", json={"excluded": value}
|
|
|
|
|
).status_code
|
|
|
|
|
== 422
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
"/api/v1/photo-sources/drive/preferences",
|
|
|
|
|
json={"item_id": "fixture", "starred": value},
|
|
|
|
|
).status_code
|
|
|
|
|
== 422
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_asset_marks_are_mutually_exclusive_under_concurrent_requests(client, service):
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
|
|
|
|
key = photo(service, "fixture.jpg", "blue")["asset_id"]
|
|
|
|
|
|
|
|
|
|
def request_star():
|
|
|
|
|
return client.put(
|
|
|
|
|
f"/api/v1/assets/{key}/star", json={"starred": True}
|
|
|
|
|
).status_code
|
|
|
|
|
|
|
|
|
|
def request_exclusion():
|
|
|
|
|
return client.put(
|
|
|
|
|
f"/api/v1/assets/{key}/exclusion", json={"excluded": True}
|
|
|
|
|
).status_code
|
|
|
|
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
|
|
|
first = pool.submit(request_star)
|
|
|
|
|
second = pool.submit(request_exclusion)
|
|
|
|
|
assert sorted([first.result(), second.result()]) == [200, 409]
|
|
|
|
|
final = service.assets.info(key)
|
|
|
|
|
assert final["starred"] != final["excluded"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_source_marks_are_mutually_exclusive_under_concurrent_requests(service):
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
|
|
|
|
def change(**mark):
|
|
|
|
|
try:
|
|
|
|
|
set_flags(service.db, "drive", "fixture", **mark)
|
|
|
|
|
return 200
|
|
|
|
|
except FlowError as error:
|
|
|
|
|
return error.status
|
|
|
|
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
|
|
|
first = pool.submit(change, starred=True)
|
|
|
|
|
second = pool.submit(change, excluded=True)
|
|
|
|
|
assert sorted([first.result(), second.result()]) == [200, 409]
|
|
|
|
|
final = flags(service.db, "drive", "fixture")
|
|
|
|
|
assert final["starred"] != final["excluded"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_batch_exclusion_rejects_starred_and_unknown_payload_fields(client, service):
|
|
|
|
|
key = photo(service, "fixture.jpg", "red")["asset_id"]
|
|
|
|
|
assert (
|
|
|
|
|
client.put(
|
|
|
|
|
f"/api/v1/assets/{key}/star", json={"starred": True, "excluded": False}
|
|
|
|
|
).status_code
|
|
|
|
|
== 422
|
|
|
|
|
)
|
|
|
|
|
client.put(f"/api/v1/assets/{key}/star", json={"starred": True})
|
|
|
|
|
result = client.post(
|
|
|
|
|
"/api/v1/assets/batch", json={"asset_ids": [key], "action": "exclude"}
|
|
|
|
|
).json()
|
|
|
|
|
assert (
|
|
|
|
|
result["results"][0]["status"] == "failed"
|
|
|
|
|
and result["results"][0]["code"] == "CONFLICT"
|
|
|
|
|
)
|
|
|
|
|
final = service.assets.info(key)
|
|
|
|
|
assert final["starred"] and not final["excluded"]
|