Files
ImageApi/dev/e2e/check_versions.py
T
Cameron CordesandClaude Opus 5.5 6135034d09 feat(versions): e2e fixtures, check_versions, docs
Fixtures gain an XMP-linked version (renamed file), a name-linked version
and an edit with a reference photo; e2e.sh counts with include_versions and
runs check_versions.py. CLAUDE.md documents the feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:53:21 -04:00

114 lines
5.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Photo-version checks (ImageApi #191) against the e2e server.
Every `versions` entry in the manifest (an XMP-linked version, a name-linked
one and one with a reference photo) must be:
- linked to its original with the expected kind, source-agnostic of path, and
the reference photo recorded as provenance only;
- hidden from `/photos` (recursive and the folder walk), `/photos/exif` and
`/photos/gps-summary`, but listed with `include_versions=true`;
- reflected on the original's row by `photo_version_counts`, and by
`photo_display_paths` once a display version is chosen;
- served on its own at `/image?path=<version>`.
The display choice is put back afterwards. Usage: check_versions.py <manifest.json>
(run via `e2e.sh check`).
"""
import json
import os
import subprocess
import sys
import urllib.parse
from pathlib import Path
E2E = Path(__file__).with_name("e2e.sh")
def api(method: str, path: str, body: dict | None = None):
cmd = [str(E2E), "api", method, path] + ([json.dumps(body)] if body is not None else [])
out = subprocess.check_output(cmd)
return json.loads(out) if out.strip() else None
def q(path: str) -> str:
return urllib.parse.quote(path)
def main() -> int:
manifest = json.load(open(sys.argv[1]))
versions = manifest.get("versions", [])
if not versions:
print("manifest has no versions; regenerate the fixtures (e2e.sh fixtures --force)")
return 1
failures: list[str] = []
def check(ok: bool, what: str) -> None:
if not ok:
failures.append(what)
listing = api("GET", "photos?path=&recursive=true&sort=nameasc")
full = api("GET", "photos?path=&recursive=true&sort=nameasc&include_versions=true")
exif = {e["file_path"] for e in api("GET", f"photos/exif?date_from=0&date_to={2**62}")["photos"]}
gps = {g["path"] for g in api("GET", "photos/gps-summary?path=")["photos"]}
for v in versions:
orig, ver = v["original"], v["version"]
name = f"{ver} ({v['source']})"
check(orig in listing["photos"], f"{orig}: original missing from /photos")
check(ver not in listing["photos"], f"{name}: version leaks into /photos")
check(ver in full["photos"], f"{name}: include_versions=true must list it")
check(ver not in exif, f"{name}: version leaks into /photos/exif")
check(ver not in gps, f"{name}: version leaks into /photos/gps-summary")
folder = ver.rsplit("/", 1)[0]
walk = api("GET", f"photos?path={q(folder)}")
check(ver not in walk["photos"], f"{name}: version leaks into the folder walk")
for ref in v["references"]:
check(ref in listing["photos"], f"{ref}: reference photo must stay visible")
# Counts ride along the original's row.
idx = listing["photos"].index(orig) if orig in listing["photos"] else -1
counts = listing.get("photo_version_counts", [])
check(idx >= 0 and idx < len(counts) and counts[idx] >= 1, f"{orig}: photo_version_counts")
# The set, asked from either side.
for asked in (orig, ver):
info = api("GET", f"image/versions?path={q(asked)}")
check(info["original"]["path"] == orig, f"{asked}: original.path")
mine = [x for x in info["versions"] if x["path"] == ver]
check(len(mine) == 1, f"{asked}: version {ver} not listed")
if mine:
check(mine[0]["kind"] == v["kind"], f"{ver}: kind {mine[0]['kind']} != {v['kind']}")
check(len(mine[0]["references"]) == len(v["references"]), f"{ver}: reference count")
for want, got in zip(v["references"], mine[0]["references"]):
check(got.get("path") == want, f"{ver}: reference path {got.get('path')} != {want}")
check(info["display_hash"] is None, f"{asked}: display_hash should start unset")
# Display choice: set from the version's own path, seen in /photos, cleared.
info = api("GET", f"image/versions?path={q(orig)}")
h = next(x["hash"] for x in info["versions"] if x["path"] == ver)
api("PUT", "image/versions/display", {"path": ver, "display_hash": h})
try:
after = api("GET", "photos?path=&recursive=true&sort=nameasc")
i = after["photos"].index(orig)
shown = after.get("photo_display_paths", [])
check(i < len(shown) and shown[i] == ver, f"{orig}: photo_display_paths after choosing {ver}")
finally:
api("PUT", "image/versions/display", {"path": orig, "display_hash": None})
# A version still downloads on its own.
data = subprocess.run([str(E2E), "api", "GET", f"image?path={q(ver)}"],
capture_output=True)
check(data.returncode == 0 and len(data.stdout) > 1000, f"{ver}: /image?path= did not serve the file")
if failures:
print("\n".join(f"FAIL {f}" for f in failures))
print(f"{len(versions)} version fixtures checked, {len(failures)} failure(s)")
return 1 if failures else 0
if __name__ == "__main__":
os.environ.setdefault("E2E_TOKEN", subprocess.check_output([str(E2E), "token"], text=True).strip())
sys.exit(main())