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}) 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}) assert ( result.status_code == 200 and result.json()["excluded"] 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" ) 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() assert not restored["excluded"] and not restored["starred"] assert ( client.put( f"/api/v1/assets/{excluded}/star", json={"starred": True} ).status_code == 200 ) 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 ) 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 ) assert client.put( url, json={"item_id": items[0]["id"], "starred": False, "excluded": True} ).json() == {"starred": False, "excluded": True} result = client.get("/api/v1/photo-sources/drive/images").json()["items"][0] assert not result["starred"] and result["excluded"] 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"]) == { "starred": False, "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() @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"]