From 82dc2d733df10e808b863c58f462e37b6606079e Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Fri, 29 May 2026 15:13:11 +0100 Subject: [PATCH] =?UTF-8?q?feat(2):=20immich=20=C2=A74.3=20asset=20upload?= =?UTF-8?q?=E2=86=92read-back=E2=86=92thumbnail=20test=20+=20PARITY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_asset_upload.py: admin-sign-up → login → POST /api/assets (multipart, unique content → 201) → GET /api/assets/{id} (200, IMAGE, read-back) → GET .../thumbnail (200, derivative generated, polled). Verified GREEN against a live immich probe (app v2.7.5). PARITY: health_check port; oidc_login non-port (authentik-specific, immich OIDC optional, keycloak-default policy). §4.3 floor + characteristic derivative-generation feature met. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/immich/PARITY.md | 28 +++++ tests/immich/functional/test_asset_upload.py | 126 +++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/immich/PARITY.md create mode 100644 tests/immich/functional/test_asset_upload.py diff --git a/tests/immich/PARITY.md b/tests/immich/PARITY.md new file mode 100644 index 0000000..ac03869 --- /dev/null +++ b/tests/immich/PARITY.md @@ -0,0 +1,28 @@ +# immich — parity map (Phase 2 P2) + recipe-specific tests (P3) + +Reference corpus: `references/recipe-maintainer/recipe-info/immich/tests/` (health_check.py, oidc_login.py). + +## Parity ports +| recipe-maintainer test | cc-ci test | what's verified | +|---|---|---| +| `health_check.py` | `tests/immich/functional/test_health_check.py::test_immich_returns_200` | HTTP 200/301/302 from `/` (immich web SPA served). | + +## Recipe-specific functional tests (P3, ≥2 — characteristic behavior) +1. **`test_asset_upload.py::test_immich_upload_asset_readback_and_thumbnail`** — the §4.3 + create-an-object + read-it-back + characteristic feature: admin-sign-up → login → **upload an + asset** (`POST /api/assets` multipart, 201 created with unique content) → **read it back** + (`GET /api/assets/{id}` → 200, type IMAGE) → **immich generated a thumbnail/derivative** + (`GET /api/assets/{id}/thumbnail` → 200, polled for the async generation). Real assertions on + stored app state + the generated derivative — immich's whole purpose (photo ingest + derivatives). + This single test covers BOTH the create+read-back floor AND the distinctive derivative-generation + feature (≥2 characteristic behaviors). Verified against immich app version 2.7.5. + +Backup data-integrity (P4): Phase-1d/1e lifecycle overlays — `ops.py` seeds a postgres `ci_marker` +(postgres/immich DB, backupbot-labelled); `test_upgrade/backup/restore.py` assert it survives. + +## Non-ports (documented — §7.1) +- **`oidc_login.py`** — authentik-specific in the corpus. Per the operator SSO policy (DECISIONS + "SSO-provider policy"), **keycloak is the default** and **Phase-2 DONE is NOT gated on authentik**; + immich's OIDC is **optional** (immich boots + the §4.3 asset flow works with a local admin, no SSO). + Porting an OIDC login test (under keycloak) is possible but not required for immich's gate; the + §4.3 functional depth above is met without it. Recorded rather than silently omitted. diff --git a/tests/immich/functional/test_asset_upload.py b/tests/immich/functional/test_asset_upload.py new file mode 100644 index 0000000..05b7131 --- /dev/null +++ b/tests/immich/functional/test_asset_upload.py @@ -0,0 +1,126 @@ +"""immich — §4.3 create-an-object + read-it-back + characteristic feature (Phase 2 P3). + +immich is a photo/video manager; its characteristic behavior is ingesting an asset and generating +derivatives (thumbnails) — exercised here end-to-end against the live deployment (no SSO needed; +immich's first-run creates a local admin): + 1. POST /api/auth/admin-sign-up {email,password,name} (first-run admin; 201, or already-exists) + 2. POST /api/auth/login → accessToken + 3. POST /api/assets (multipart: assetData PNG + deviceAssetId/deviceId/fileCreatedAt/fileModifiedAt) + → 201 {id} — CREATE the object + 4. GET /api/assets/{id} → 200, asset id matches + type IMAGE — READ IT BACK + 5. GET /api/assets/{id}/thumbnail → 200 — immich GENERATED a derivative (poll; async) + +Verified against the deployed immich app version 2.7.5 (GET /api/server/version → {2,7,5}). Real +assertions on app state (the stored asset + its generated thumbnail), not a health/200 stand-in. +""" + +from __future__ import annotations + +import json +import os +import ssl +import sys +import time +import urllib.request +import uuid + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http # noqa: E402 + +# A valid 1x1 PNG (so immich's decoder + thumbnailer accept it and generate a derivative). +_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d4948445200000001000000010806000000" + "1f15c4890000000a49444154789c6300010000050001" + "0d0a2db40000000049454e44ae426082" +) + +_ADMIN = {"email": "ci@ccci.local", "password": "ccciPass12345", "name": "ci"} + + +def _ctx(): + c = ssl.create_default_context() + c.check_hostname = False + c.verify_mode = ssl.CERT_NONE + return c + + +def _upload_asset(base: str, token: str, device_asset_id: str) -> tuple[int, dict | None]: + """Multipart POST /api/assets (immich's upload endpoint). Returns (status, json).""" + boundary = "----ccci" + uuid.uuid4().hex + parts: list[bytes] = [] + + def field(name: str, value: str) -> None: + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode() + ) + + field("deviceAssetId", device_asset_id) + field("deviceId", "ccci") + field("fileCreatedAt", "2026-05-29T00:00:00.000Z") + field("fileModifiedAt", "2026-05-29T00:00:00.000Z") + # Append unique trailing bytes after IEND so each run's file has a UNIQUE checksum — immich dedups + # by content hash, so without this a re-run (or a probe that already has this exact PNG) returns + # 200 "duplicate" instead of 201 "created". PNG decoders ignore post-IEND bytes, so it's still a + # valid 1x1 image the thumbnailer can process. + png = _PNG + device_asset_id.encode() + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="ccci.png"\r\n' + f"Content-Type: image/png\r\n\r\n".encode() + + png + + b"\r\n" + ) + parts.append(f"--{boundary}--\r\n".encode()) + body = b"".join(parts) + req = urllib.request.Request(f"{base}/api/assets", data=body, method="POST") + req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=60, context=_ctx()) as r: + return r.status, json.loads(r.read() or b"null") + except urllib.error.HTTPError as e: # noqa: PERF203 + try: + return e.code, json.loads(e.read() or b"null") + except Exception: # noqa: BLE001 + return e.code, None + + +def test_immich_upload_asset_readback_and_thumbnail(live_app): + base = f"https://{live_app}" + + # 1) first-run admin (201 created; tolerate 400 if one already exists from a prior tier) + st, _ = harness_http.http_post(f"{base}/api/auth/admin-sign-up", data=_ADMIN) + assert st in (201, 400, 409), f"admin-sign-up unexpected HTTP {st}" + + # 2) login → accessToken + st, body = harness_http.http_post( + f"{base}/api/auth/login", data={"email": _ADMIN["email"], "password": _ADMIN["password"]} + ) + assert st in (200, 201) and isinstance(body, dict), f"login HTTP {st}: {body!r}" + token = body.get("accessToken") + assert token and isinstance(token, str), f"login returned no accessToken: {body!r}" + auth = {"Authorization": f"Bearer {token}"} + + # 3) upload an asset (CREATE the object) — unique deviceAssetId so immich doesn't dedup + st, up = _upload_asset(base, token, f"ccci-{uuid.uuid4().hex}") + assert st == 201 and isinstance(up, dict), f"asset upload HTTP {st}: {up!r}" + asset_id = up.get("id") + assert asset_id, f"upload returned no asset id: {up!r}" + + # 4) READ IT BACK — GET the asset; assert it's the one we uploaded, an IMAGE + st, got = harness_http.http_request("GET", f"{base}/api/assets/{asset_id}", headers=auth) + assert st == 200 and isinstance(got, dict), f"asset read-back HTTP {st}: {got!r}" + assert got.get("id") == asset_id, f"read-back asset id {got.get('id')!r} != {asset_id!r}" + assert got.get("type") == "IMAGE", f"read-back asset type {got.get('type')!r} != IMAGE" + + # 5) immich GENERATED a derivative — the thumbnail (poll; generation is async) + thumb = 0 + for _ in range(12): + thumb, _ = harness_http.http_request( + "GET", f"{base}/api/assets/{asset_id}/thumbnail", headers=auth + ) + if thumb == 200: + break + time.sleep(5) + assert thumb == 200, ( + f"immich did not generate a thumbnail/derivative for the uploaded asset (last HTTP {thumb})" + )