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>
114 lines
5.1 KiB
Python
Executable File
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())
|