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>
This commit is contained in:
1 parent
fa3554c411
commit
6135034d09
5 files changed
+394
-6
No files matched your search
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user