import copy 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 browser(client, service, monkeypatch): photos = service.photo_sources photos.save( "family", PhotoSourceInput( name="Family", kind="synology-photos", location="albums", people_filter=True, person_ids=[7], ), ) monkeypatch.setattr(photos.synology, "sid", lambda: "test-session") monkeypatch.setattr( photos, "catalog", lambda *a, **kw: pytest.fail("Must not scan full catalog") ) rows = [ { "id": i, "type": "photo", "filename": f"Photo-{i}.jpg", "filesize": 2000000, "time": int(datetime(2026, 10, 20, 2, tzinfo=UTC).timestamp()), "indexed_time": int(datetime(2026, 10, 21, 2, tzinfo=UTC).timestamp()), "additional": { "folder": "albums/travel", "resolution": {"width": 1600, "height": 1200}, "person": [{"id": 7, "name": "Person"}], "thumbnail": {"cache_key": "x", "unit_id": i}, }, } for i in range(1, 201) ] calls = [] def request(api, method, version, **params): calls.append(params) assert params["limit"] <= 60 selected = rows if params.get("person_id"): selected = [ r for r in rows if any( p["id"] == params["person_id"] for p in r["additional"]["person"] ) ] return { "list": copy.deepcopy( selected[params["offset"] : params["offset"] + params["limit"]] ) } monkeypatch.setattr(photos.synology, "request", request) return photos, rows, calls def test_bounded_paging_preview_and_import(client, service, browser, monkeypatch): photos, _, calls = browser first = client.get("/api/v1/source-libraries/family/browse?limit=12").json() assert len(first["items"]) == 12 and first["next_offset"] == 12 and len(calls) == 1 second = client.get( "/api/v1/source-libraries/family/browse?limit=12&offset=12" ).json() assert not {x["id"] for x in first["items"]} & {x["id"] for x in second["items"]} image = BytesIO() Image.new("RGB", (1600, 1200)).save(image, format="JPEG") downloads = [] def download(row, **kwargs): downloads.append(kwargs) return image.getvalue() monkeypatch.setattr(photos.synology, "download", download) monkeypatch.setattr( photos.synology, "item_people", lambda *a, **kw: pytest.fail("No full person album lookup"), ) assert client.get(first["items"][0]["preview_url"]).status_code == 200 assert downloads[-1]["size"] == "m" # Optional captions must use metadata already returned on this page. source = photos.get("family").model_copy(update={"folder_caption": True}) photos.save("family", source) client.get("/api/v1/source-libraries/family/browse?limit=12") r = client.post( "/api/v1/source-libraries/family/import", json={"item_id": first["items"][0]["id"]}, ) assert r.status_code == 200, r.text assert "size" not in downloads[-1] # Default XL for saved source pixels. assert r.json()["caption"]["people"] == "Person" def test_sparse_search_is_bounded_and_can_continue(client, browser): _, _, calls = browser r = client.get( "/api/v1/source-libraries/family/browse?filename=not-found&limit=10" ).json() assert r["items"] == [] and r["scanned"] == 40 and r["next_offset"] == 40 assert len(calls) == 4 def test_metadata_filters_and_source_scope(client, browser): photos, rows, _ = browser rows[0]["additional"]["folder"] = "albums-other/secret" rows[1]["additional"]["person"] = [{"id": 8, "name": "Other"}] rows[2]["filesize"] = 100 rows[3]["additional"]["resolution"]["width"] = 200 rows[4]["indexed_time"] = 1 params = { "limit": 8, "filename": "PHOTO-", "folder": "TRAVEL", "min_bytes": 1000000, "max_bytes": 3000000, "min_width": 1000, "max_width": 2000, "min_height": 1000, "max_height": 2000, "date_from": "2026-10-21", "date_to": "2026-10-21", "person_id": 7, } r = client.get("/api/v1/source-libraries/family/browse", params=params) assert r.status_code == 200, r.text assert {i["id"] for i in r.json()["items"]}.isdisjoint({"1", "2", "3", "4", "5"}) assert r.json()["items"] assert photos.browser.cached("family", "1") is None assert ( client.get("/api/v1/source-libraries/family/browse/1/preview").status_code == 404 ) # Metadata already cached must not survive a source scope change. item_id = r.json()["items"][0]["id"] photos.save("family", photos.get("family").model_copy(update={"location": "other"})) assert photos.browser.cached("family", item_id) is None @pytest.mark.parametrize( "query", [ "limit=1000", "offset=-1", "min_width=-1", "min_bytes=20&max_bytes=10", "date_from=2026-10-22&date_to=2026-10-20", "person_id=-1", "date_field=unknown", "unexpected=1", ], ) def test_invalid_filters_rejected(client, browser, query): assert ( client.get("/api/v1/source-libraries/family/browse?" + query).status_code == 422 ) def test_session_changes_invalidate_preview(client, browser, monkeypatch): photos, _, _ = browser item = client.get("/api/v1/source-libraries/family/browse?limit=1").json()["items"][ 0 ] monkeypatch.setattr(photos.synology, "sid", lambda: "new-session") assert client.get(item["preview_url"]).status_code == 404