2026-10-16 22:31:13 +08:00
|
|
|
import hashlib
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
from pigway_frame_flow.errors import FlowError
|
|
|
|
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
|
|
|
|
from pigway_frame_flow.providers.photos_cache import PhotosCache
|
|
|
|
|
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def jpeg(color="red"):
|
|
|
|
|
out = io.BytesIO()
|
|
|
|
|
Image.new("RGB", (24, 16), color).save(out, "JPEG")
|
|
|
|
|
return out.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def session(settings, value="fixture-session"):
|
|
|
|
|
settings.config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
(settings.config_dir / "synology-photos-session.json").write_text(
|
|
|
|
|
json.dumps({"sid": value})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def row(id, folder="/family", name="same.jpg"):
|
|
|
|
|
return {
|
|
|
|
|
"id": id,
|
|
|
|
|
"filename": name,
|
|
|
|
|
"type": "photo",
|
|
|
|
|
"filesize": 9000,
|
|
|
|
|
"indexed_time": 42,
|
|
|
|
|
"additional": {
|
|
|
|
|
"folder": folder,
|
|
|
|
|
"resolution": {"width": 24, "height": 16},
|
|
|
|
|
"thumbnail": {"unit_id": id, "cache_key": "fixture-key"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"space,prefix", [("shared", "SYNO.FotoTeam"), ("personal", "SYNO.Foto")]
|
|
|
|
|
)
|
|
|
|
|
@pytest.mark.parametrize("mode,expected", [("any", ["1", "2", "3"]), ("all", ["2"])])
|
|
|
|
|
def test_api_identity_selection(settings, monkeypatch, space, prefix, mode, expected):
|
|
|
|
|
session(settings)
|
|
|
|
|
p = SynologyPhotos(settings)
|
|
|
|
|
monkeypatch.setattr(p, "people", lambda space: [{"id": 10}, {"id": 20}])
|
|
|
|
|
|
|
|
|
|
def pages(api, version, **params):
|
|
|
|
|
assert api == prefix + ".Browse.Item"
|
|
|
|
|
return [row(i) for i in ([1, 2] if params["person_id"] == 10 else [2, 3])]
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(p, "pages", pages)
|
|
|
|
|
assert [str(x["id"]) for x in p.items([10, 20], mode, space=space)] == expected
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
p.items([99], space=space)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_people_pagination_does_not_stop_at_500(settings, monkeypatch):
|
|
|
|
|
session(settings)
|
|
|
|
|
p = SynologyPhotos(settings)
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def request(api, method, version, **params):
|
|
|
|
|
calls.append(params["offset"])
|
|
|
|
|
return {
|
|
|
|
|
"list": [
|
|
|
|
|
{"id": i}
|
2026-10-18 16:14:56 +08:00
|
|
|
for i in range(params["offset"], min(1203, params["offset"] + 500))
|
2026-10-16 22:31:13 +08:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(p, "request", request)
|
2026-10-18 16:14:56 +08:00
|
|
|
assert len(p.people()) == 1203
|
2026-10-16 22:31:13 +08:00
|
|
|
assert calls == [0, 500, 1000]
|
|
|
|
|
|
|
|
|
|
|
2026-10-17 23:28:04 +08:00
|
|
|
def test_caption_people_use_actual_membership_and_cache(settings, monkeypatch):
|
|
|
|
|
session(settings)
|
|
|
|
|
provider = SynologyPhotos(settings)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
provider,
|
|
|
|
|
"people",
|
|
|
|
|
lambda space: [
|
|
|
|
|
{"id": 1, "name": "Alice", "named": True},
|
|
|
|
|
{"id": 2, "name": "Bob", "named": True},
|
|
|
|
|
{"id": 3, "name": "3", "named": False},
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def pages(api, version, **params):
|
|
|
|
|
calls.append(api)
|
|
|
|
|
return [{"id": params["person_id"]}, {"id": 10}]
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(provider, "pages", pages)
|
|
|
|
|
assert provider.item_people(1) == ["Alice"]
|
|
|
|
|
assert provider.item_people(10) == ["Alice", "Bob"]
|
|
|
|
|
assert provider.item_people(99) == []
|
|
|
|
|
assert calls == ["SYNO.FotoTeam.Browse.Item"] * 2
|
|
|
|
|
assert provider.item_people(10, space="personal") == ["Alice", "Bob"]
|
|
|
|
|
assert calls[-2:] == ["SYNO.Foto.Browse.Item"] * 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("labels_available", [True, False])
|
2026-10-18 08:19:03 +08:00
|
|
|
def test_api_caption_keeps_folder_and_optional_people_without_filename(
|
2026-10-17 23:28:04 +08:00
|
|
|
service, settings, monkeypatch, labels_available
|
|
|
|
|
):
|
|
|
|
|
session(settings)
|
|
|
|
|
provider = service.photo_sources
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
provider.synology,
|
|
|
|
|
"items",
|
|
|
|
|
lambda *a, **k: [row(1, "/20261017 Family@Park", "one.jpg")],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(provider.synology, "download", lambda *a, **k: jpeg())
|
|
|
|
|
|
|
|
|
|
def names(*a, **k):
|
|
|
|
|
if not labels_available:
|
|
|
|
|
raise FlowError("CLOUD_UNAVAILABLE", "unavailable", 502)
|
|
|
|
|
return ["Alice", "Bob"]
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(provider.synology, "item_people", names)
|
|
|
|
|
provider.save(
|
|
|
|
|
"captions",
|
|
|
|
|
PhotoSourceInput(
|
|
|
|
|
name="Family", kind="synology-photos", location=".", folder_caption=True
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
_, _, caption = provider.read("captions", "1")
|
|
|
|
|
assert caption["date"] == "2026-10-17"
|
|
|
|
|
assert caption["title"] == "Family"
|
2026-10-18 08:19:03 +08:00
|
|
|
assert "filename" not in caption
|
2026-10-18 00:12:28 +08:00
|
|
|
assert caption.get("people", "") == ("Alice & Bob" if labels_available else "")
|
2026-10-17 23:28:04 +08:00
|
|
|
|
|
|
|
|
|
2026-10-16 22:31:13 +08:00
|
|
|
def test_source_without_mount_pagination_scope_and_session(
|
|
|
|
|
client, service, settings, monkeypatch
|
|
|
|
|
):
|
|
|
|
|
session(settings)
|
|
|
|
|
settings.photo_root = settings.data_dir / "must-not-exist"
|
|
|
|
|
p = service.photo_sources
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
p.synology,
|
|
|
|
|
"items",
|
|
|
|
|
lambda *a, **k: [row(1), row(2, "/family/child"), row(3, "/other")],
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(p.synology, "download", lambda *a, **k: jpeg())
|
|
|
|
|
source = PhotoSourceInput(
|
|
|
|
|
name="API",
|
|
|
|
|
kind="synology-photos",
|
|
|
|
|
location="family",
|
|
|
|
|
people_filter=True,
|
|
|
|
|
person_ids=[10],
|
|
|
|
|
orientation="landscape",
|
|
|
|
|
)
|
|
|
|
|
p.save("api", source)
|
|
|
|
|
response = client.get("/api/v1/photo-sources/api/images?offset=1&limit=1")
|
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
assert response.json()["total"] == 2
|
|
|
|
|
assert response.json()["items"][0]["id"] == "2"
|
|
|
|
|
assert "cache_key" not in response.text and "_photos" not in response.text
|
|
|
|
|
data, name, _ = p.read("api", "1")
|
|
|
|
|
Image.open(io.BytesIO(data)).load()
|
|
|
|
|
assert name == "same.jpg" and not settings.photo_root.exists()
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
p.read("api", "3")
|
|
|
|
|
p.synology.disconnect()
|
|
|
|
|
with pytest.raises(FlowError) as error:
|
|
|
|
|
p.catalog("api")
|
|
|
|
|
assert error.value.status == 409
|
|
|
|
|
session(settings, "different-account")
|
|
|
|
|
monkeypatch.setattr(p.synology, "items", lambda *a, **k: [])
|
|
|
|
|
assert p.catalog("api") == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_existing_local_sources_are_not_silently_converted(service, settings):
|
|
|
|
|
settings.photo_root = settings.data_dir / "missing"
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
service.photo_sources.save(
|
|
|
|
|
"local", PhotoSourceInput(name="Legacy", kind="local", location=".")
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
service.photo_sources.save(
|
|
|
|
|
"api",
|
|
|
|
|
PhotoSourceInput(name="API", kind="synology-photos", location="../secrets"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cache_coalesces_concurrency_and_recovers_partial_files(settings):
|
|
|
|
|
settings.photos_cache_bytes = 2000
|
|
|
|
|
settings.max_bytes = 1000
|
|
|
|
|
cache = PhotosCache(settings)
|
|
|
|
|
|
|
|
|
|
def download(handle, cap, deadline):
|
|
|
|
|
handle.write(jpeg())
|
|
|
|
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
|
|
|
results = list(pool.map(lambda _: cache.read("one", download), range(8)))
|
|
|
|
|
assert all(x == jpeg() for x in results)
|
|
|
|
|
assert cache.downloads == 1 and cache.hits == 7
|
|
|
|
|
(cache.root / "interrupted.part").write_bytes(b"partial")
|
|
|
|
|
cache.read("two", download)
|
|
|
|
|
cache.read("three", download)
|
|
|
|
|
assert sum(p.stat().st_size for p in cache.root.glob("*.image")) <= 2000
|
|
|
|
|
assert not list(cache.root.glob("*.part"))
|
|
|
|
|
assert cache.read("three", download) == jpeg()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["interrupt", "invalid", "oversize"])
|
|
|
|
|
def test_cache_failures_never_commit(settings, failure):
|
|
|
|
|
settings.max_bytes = 1000
|
|
|
|
|
cache = PhotosCache(settings)
|
|
|
|
|
|
|
|
|
|
def download(handle, cap, deadline):
|
|
|
|
|
handle.write(
|
|
|
|
|
jpeg()
|
|
|
|
|
if failure == "interrupt"
|
|
|
|
|
else b"wrong"
|
|
|
|
|
if failure == "invalid"
|
|
|
|
|
else b"x" * 1001
|
|
|
|
|
)
|
|
|
|
|
if failure == "interrupt":
|
|
|
|
|
raise TimeoutError()
|
|
|
|
|
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
cache.read("bad", download)
|
|
|
|
|
assert not list(cache.root.glob("*.part"))
|
|
|
|
|
assert not list(cache.root.glob("*.image"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def transport(
|
|
|
|
|
settings, monkeypatch, *, bad_pin=False, expired=False, interrupted=False
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.setenv("SYNOLOGY_PHOTOS_URL", "https://nas.test:9115")
|
|
|
|
|
monkeypatch.setenv(
|
|
|
|
|
"SYNOLOGY_PHOTOS_CERT_SHA256",
|
|
|
|
|
"a" * 64 if bad_pin else hashlib.sha256(b"certificate").hexdigest(),
|
|
|
|
|
)
|
|
|
|
|
sent = []
|
|
|
|
|
|
|
|
|
|
class Socket:
|
|
|
|
|
def getpeercert(self, **kw):
|
|
|
|
|
return b"certificate"
|
|
|
|
|
|
|
|
|
|
class Response:
|
|
|
|
|
status = 200
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.buffer = io.BytesIO(
|
|
|
|
|
b'{"success":false,"error":{"code":106}}' if expired else jpeg()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def getheader(self, name):
|
|
|
|
|
return (
|
|
|
|
|
("application/json" if expired else "image/jpeg")
|
|
|
|
|
if name == "Content-Type"
|
|
|
|
|
else str(len(self.buffer.getvalue()))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def read(self, size):
|
|
|
|
|
if interrupted:
|
|
|
|
|
raise TimeoutError()
|
|
|
|
|
return self.buffer.read(size)
|
|
|
|
|
|
|
|
|
|
class Connection:
|
|
|
|
|
sock = Socket()
|
|
|
|
|
|
|
|
|
|
def __init__(self, *a, **kw):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def connect(self):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def request(self, *args):
|
|
|
|
|
sent.append(args)
|
|
|
|
|
|
|
|
|
|
def getresponse(self):
|
|
|
|
|
return Response()
|
|
|
|
|
|
|
|
|
|
def close(self):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("http.client.HTTPSConnection", Connection)
|
|
|
|
|
session(settings)
|
|
|
|
|
return SynologyPhotos(settings), sent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_binary_transport_pin_before_secret_and_read_allowlist(settings, monkeypatch):
|
|
|
|
|
p, sent = transport(settings, monkeypatch, bad_pin=True)
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
p.download(row(1))
|
|
|
|
|
assert not sent
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
p._download(
|
|
|
|
|
"SYNO.FotoTeam.Browse.Item",
|
|
|
|
|
"delete",
|
|
|
|
|
{},
|
|
|
|
|
"fixture",
|
|
|
|
|
io.BytesIO(),
|
|
|
|
|
1000,
|
|
|
|
|
100,
|
|
|
|
|
)
|
|
|
|
|
assert not sent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_download_decodes_cache_reuse_and_rendition_rejection(settings, monkeypatch):
|
|
|
|
|
p, sent = transport(settings, monkeypatch)
|
|
|
|
|
assert p.download(row(1)) == jpeg()
|
|
|
|
|
assert p.download(row(1)) == jpeg()
|
|
|
|
|
assert len(sent) == 1
|
|
|
|
|
assert "SYNO.FotoTeam.Thumbnail" in sent[0][2]
|
|
|
|
|
assert p.download(row(1), space="personal") == jpeg()
|
|
|
|
|
assert len(sent) == 2
|
|
|
|
|
session(settings, "different-session")
|
|
|
|
|
assert p.download(row(1)) == jpeg()
|
|
|
|
|
assert len(sent) == 3
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
p.download(row(1), rendition="original")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_download_session_error_and_interruption(settings, monkeypatch):
|
|
|
|
|
p, _ = transport(settings, monkeypatch, expired=True)
|
|
|
|
|
with pytest.raises(FlowError) as error:
|
|
|
|
|
p.download(row(1))
|
|
|
|
|
assert error.value.status == 409
|
|
|
|
|
assert not p.path.exists()
|
|
|
|
|
assert not list(p.image_cache.root.glob("*.part"))
|
|
|
|
|
p, _ = transport(settings, monkeypatch, interrupted=True)
|
|
|
|
|
with pytest.raises(FlowError):
|
|
|
|
|
p.download(row(1))
|
|
|
|
|
assert not list(p.image_cache.root.glob("*.part"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cache_process(settings, marker):
|
|
|
|
|
cache = PhotosCache(settings)
|
|
|
|
|
|
|
|
|
|
def download(handle, cap, deadline):
|
|
|
|
|
with open(marker, "ab") as counter:
|
|
|
|
|
counter.write(b"download\n")
|
|
|
|
|
handle.write(jpeg())
|
|
|
|
|
|
|
|
|
|
assert cache.read("shared-process-key", download) == jpeg()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cache_coalesces_across_processes(settings, tmp_path):
|
|
|
|
|
import multiprocessing
|
|
|
|
|
|
|
|
|
|
context = multiprocessing.get_context("spawn")
|
|
|
|
|
marker = tmp_path / "downloads"
|
|
|
|
|
processes = [
|
|
|
|
|
context.Process(target=cache_process, args=(settings, marker)) for _ in range(3)
|
|
|
|
|
]
|
|
|
|
|
for process in processes:
|
|
|
|
|
process.start()
|
|
|
|
|
for process in processes:
|
|
|
|
|
process.join(15)
|
|
|
|
|
assert process.exitcode == 0
|
|
|
|
|
assert marker.read_bytes() == b"download\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_explicit_offline_conversion_preserves_task_reference(service, settings):
|
|
|
|
|
import importlib.util
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
|
|
|
"photos_migration", Path(__file__).parents[1] / "scripts/migrate-photos-api.py"
|
|
|
|
|
)
|
|
|
|
|
migration = importlib.util.module_from_spec(spec)
|
|
|
|
|
spec.loader.exec_module(migration)
|
|
|
|
|
# The production service uses this filename; inspect the fixture connection
|
|
|
|
|
# rather than modifying an unrelated database.
|
|
|
|
|
path = Path(service.db.conn.execute("PRAGMA database_list").fetchone()[2])
|
|
|
|
|
source = PhotoSourceInput(
|
|
|
|
|
name="Existing",
|
|
|
|
|
kind="local",
|
|
|
|
|
location="family",
|
|
|
|
|
people_filter=True,
|
|
|
|
|
person_ids=[5],
|
|
|
|
|
synology_previews=True,
|
|
|
|
|
people_space="personal",
|
|
|
|
|
person_match="all",
|
|
|
|
|
)
|
|
|
|
|
service.db.execute(
|
|
|
|
|
"INSERT INTO app_config VALUES(?,?)",
|
|
|
|
|
("photo_source:existing", source.model_dump_json()),
|
|
|
|
|
)
|
|
|
|
|
before = service.photo_sources.get("existing")
|
|
|
|
|
assert not migration.convert(path, ["existing"])["applied"]
|
|
|
|
|
assert service.photo_sources.get("existing") == before
|
|
|
|
|
result = migration.convert(path, ["existing"], apply=True)
|
|
|
|
|
assert Path(result["backup"]).is_file()
|
|
|
|
|
after = service.photo_sources.get("existing")
|
|
|
|
|
assert after.kind == "synology-photos" and not after.synology_previews
|
|
|
|
|
assert (
|
|
|
|
|
after.person_ids == [5]
|
|
|
|
|
and after.people_space == "personal"
|
|
|
|
|
and after.person_match == "all"
|
|
|
|
|
and after.location == "family"
|
|
|
|
|
)
|
2026-10-18 17:43:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("space", ["shared", "personal"])
|
|
|
|
|
@pytest.mark.parametrize("exif", [5, 6, 7, 8])
|
2026-10-18 17:59:02 +08:00
|
|
|
def test_api_filters_already_oriented_resolution_without_rotating_twice(
|
2026-10-18 17:43:25 +08:00
|
|
|
service, settings, monkeypatch, space, exif
|
|
|
|
|
):
|
|
|
|
|
session(settings)
|
|
|
|
|
photos = service.photo_sources
|
|
|
|
|
requested = []
|
|
|
|
|
|
|
|
|
|
def pages(api, version, **params):
|
|
|
|
|
requested[:] = json.loads(params["additional"])
|
|
|
|
|
item = row(1)
|
2026-10-18 17:59:02 +08:00
|
|
|
# Photos returns display dimensions even when EXIF retains a rotation.
|
|
|
|
|
item["additional"]["resolution"] = {"width": 16, "height": 24}
|
2026-10-18 17:43:25 +08:00
|
|
|
if "orientation" in requested:
|
|
|
|
|
item["additional"]["orientation"] = exif
|
|
|
|
|
return [item]
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(photos.synology, "pages", pages)
|
|
|
|
|
photos.save(
|
|
|
|
|
"direction",
|
|
|
|
|
PhotoSourceInput(
|
|
|
|
|
name="Fixture",
|
|
|
|
|
kind="synology-photos",
|
|
|
|
|
location=".",
|
|
|
|
|
people_space=space,
|
|
|
|
|
orientation="landscape",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
assert photos.catalog("direction") == []
|
|
|
|
|
photos.save(
|
|
|
|
|
"direction",
|
|
|
|
|
photos.get("direction").model_copy(update={"orientation": "portrait"}),
|
|
|
|
|
)
|
|
|
|
|
items = photos.catalog("direction")
|
|
|
|
|
assert "orientation" in requested
|
|
|
|
|
assert [(x["width"], x["height"]) for x in items] == [(16, 24)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("rotation", [1, 8])
|
|
|
|
|
def test_fetched_photo_direction_is_checked_before_cropping(
|
|
|
|
|
service, settings, monkeypatch, rotation
|
|
|
|
|
):
|
|
|
|
|
session(settings)
|
|
|
|
|
photos = service.photo_sources
|
|
|
|
|
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [row(1)])
|
|
|
|
|
out = io.BytesIO()
|
|
|
|
|
exif = Image.Exif()
|
|
|
|
|
exif[274] = rotation
|
|
|
|
|
size = (16, 24) if rotation == 1 else (24, 16)
|
|
|
|
|
Image.new("RGB", size, "blue").save(out, "JPEG", exif=exif)
|
|
|
|
|
monkeypatch.setattr(photos.synology, "download", lambda *a, **k: out.getvalue())
|
|
|
|
|
photos.save(
|
|
|
|
|
"direction",
|
|
|
|
|
PhotoSourceInput(
|
|
|
|
|
name="Fixture",
|
|
|
|
|
kind="synology-photos",
|
|
|
|
|
location=".",
|
|
|
|
|
orientation="landscape",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(FlowError, match="direction"):
|
|
|
|
|
photos.read("direction", "1")
|
|
|
|
|
|
|
|
|
|
# A correctly selected portrait remains readable, including EXIF-only rotation.
|
|
|
|
|
item = row(1)
|
|
|
|
|
item["additional"]["orientation"] = 8
|
2026-10-18 17:59:02 +08:00
|
|
|
item["additional"]["resolution"] = {"width": 16, "height": 24}
|
2026-10-18 17:43:25 +08:00
|
|
|
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [item])
|
|
|
|
|
photos.save(
|
|
|
|
|
"direction",
|
|
|
|
|
photos.get("direction").model_copy(update={"orientation": "portrait"}),
|
|
|
|
|
)
|
|
|
|
|
assert photos.read("direction", "1")[0] == out.getvalue()
|