#!/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=`. The display choice is put back afterwards. Usage: check_versions.py (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())