Files
imagefind/scripts/live-api-acceptance.py
T

191 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Exercise mutable live APIs using only run-owned ImageFind records."""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
from typing import Any
import httpx
def call(client: httpx.Client, method: str, path: str, **kwargs: Any) -> httpx.Response:
response = client.request(method, path, **kwargs)
if response.is_error:
raise RuntimeError(f"{method} {path} -> {response.status_code}: {response.text[:1000]}")
return response
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", required=True)
auth = parser.add_mutually_exclusive_group(required=True)
auth.add_argument("--token-file", type=Path)
auth.add_argument("--password")
parser.add_argument("--run-dir", type=Path, required=True)
parser.add_argument("--case-id", default="")
args = parser.parse_args()
state = json.loads((args.run_dir / "state.json").read_text())
headers = {}
if args.token_file is not None:
headers["Authorization"] = f"Bearer {args.token_file.read_text().strip()}"
client = httpx.Client(
base_url=args.base_url.rstrip("/"),
headers=headers,
timeout=httpx.Timeout(60, connect=10),
)
if args.password is not None:
login = call(
client,
"POST",
"/api/v1/auth/login",
json={"password": args.password, "remember_device": False},
).json()
csrf = str(login.get("csrf_token") or "")
if not csrf:
raise AssertionError("login response omitted CSRF token")
client.headers["X-CSRF-Token"] = csrf
case_prefix = f"-{args.case_id.strip()}" if args.case_id.strip() else ""
expected_names = {
f"{state['run_id']}{case_prefix}-e2e-positive.mp4",
f"{state['run_id']}{case_prefix}-e2e-negative.mp4",
f"{state['run_id']}{case_prefix}-e2e-fallback.mpg",
}
videos = call(client, "GET", "/api/v1/videos?limit=500").json()
owned = {
item["source_key"].rsplit("/", 1)[-1]: item
for item in videos
if item["source_id"] == state["source_id"]
and item["source_key"].rsplit("/", 1)[-1] in expected_names
}
if set(owned) != expected_names:
raise AssertionError(f"unexpected run-owned videos: {sorted(owned)}")
positive = owned[f"{state['run_id']}{case_prefix}-e2e-positive.mp4"]
video_id = positive["id"]
metadata = {
"title": "星河验收影片 729",
"catalog_code": "IF-E2E-729",
"studio": "ImageFind 验收工作室",
"series": f"{state['run_id']}-合集",
"release_date": "2026-08-02",
"description": "用于验证标题、番号、片商、合集、日期、简介、演员和分类同步。",
"actors": ["测试演员 729"],
"tags": [],
"tag_ids": [state["tag_id"]],
}
call(client, "PATCH", f"/api/v1/videos/{video_id}/metadata", json=metadata)
call(
client,
"PATCH",
f"/api/v1/videos/{video_id}/state",
json={"liked": True, "favorited": True, "progress_ms": 4000, "completed": False},
)
refreshed = call(client, "GET", "/api/v1/videos?limit=500").json()
positive = next(item for item in refreshed if item["id"] == video_id)
for key in ("title", "catalog_code", "studio", "release_date", "description"):
if positive[key] != metadata[key]:
raise AssertionError(f"metadata mismatch {key}: {positive[key]!r}")
if positive["actors"] != ["测试演员 729"]:
raise AssertionError(f"actors mismatch: {positive['actors']}")
if positive["tag_items"][0]["id"] != state["tag_id"]:
raise AssertionError("tag assignment mismatch")
if not positive["liked"] or not positive["favorited"] or positive["progress_ms"] != 4000:
raise AssertionError("video state mismatch")
# Upload completion deliberately precedes the independent AI lane. The
# search result contract is frame-based, so wait for basic parsing instead
# of racing the freshly queued index job.
deadline = time.monotonic() + 300
while positive.get("index_state", {}).get("basic") == "pending":
if time.monotonic() >= deadline:
raise TimeoutError("basic video indexing did not reach a terminal state")
time.sleep(2)
refreshed = call(client, "GET", "/api/v1/videos?limit=500").json()
positive = next(item for item in refreshed if item["id"] == video_id)
filters = {}
for name, query in {
"favorite": "favorite=true",
"liked": "liked=true",
"played": "played_only=true&sort=last_played",
}.items():
result = call(client, "GET", f"/api/v1/videos?{query}&limit=500").json()
filters[name] = [item["id"] for item in result]
if video_id not in filters[name]:
raise AssertionError(f"{name} filter omitted the updated video")
profile = call(client, "GET", "/api/v1/profile").json()
expected_profile_counts = {"favorites": 1, "likes": 1}
for key, minimum in expected_profile_counts.items():
if int(profile.get("counts", {}).get(key, 0)) < minimum:
raise AssertionError(f"profile count did not include {key}")
collection = call(client, "GET", f"/api/v1/collections/{state['collection_id']}").json()
if video_id not in [item["id"] for item in collection["videos"]]:
raise AssertionError("collection detail omitted the updated video")
search = call(
client,
"POST",
"/api/v1/search",
json={"text": "IF-E2E-729", "recognition_types": ["metadata"]},
).json()
search_ids = [item["video_id"] for item in search["items"]]
search_deferred = positive.get("index_state", {}).get("basic") == "failed"
if video_id not in search_ids and not search_deferred:
raise AssertionError("metadata search did not return the matching video")
negative_search = call(
client,
"POST",
"/api/v1/search",
json={"text": "绝不应命中的验收词 9834721", "recognition_types": ["metadata"]},
).json()
if negative_search["items"]:
raise AssertionError("non-matching metadata search unexpectedly returned a video")
stream = call(
client,
"GET",
f"/api/v1/videos/{video_id}/stream",
headers={"Range": "bytes=0-1023"},
)
if stream.status_code != 206 or len(stream.content) != 1024:
raise AssertionError(f"range stream mismatch: {stream.status_code}, {len(stream.content)}")
with client.stream("GET", f"/api/v1/videos/{video_id}/download") as download:
if download.status_code != 200:
raise AssertionError(f"download status {download.status_code}")
disposition = download.headers.get("content-disposition", "")
if "attachment" not in disposition.lower():
raise AssertionError(f"download disposition mismatch: {disposition}")
first = next(download.iter_bytes(), b"")
if not first:
raise AssertionError("download returned an empty body")
report = {
"video_id": video_id,
"metadata": {key: positive[key] for key in metadata if key not in {"actors", "tags", "tag_ids"}},
"actors": positive["actors"],
"filters": filters,
"profile_counts": profile.get("counts", {}),
"collection_video_count": collection["video_count"],
"metadata_search_matches": len(search["items"]),
"metadata_search_deferred": search_deferred,
"range_stream": {"status": stream.status_code, "bytes": len(stream.content)},
"download": {"status": 200, "content_disposition": disposition},
}
(args.run_dir / "api-report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n"
)
state["positive_video_id"] = video_id
(args.run_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()