"""Operational diagnostics and the operations API (US07-05). What an operator needs before a mutating stage runs: how much space each growing component is using, how much is left, whether anything else is holding the library, and whether the newest backup is still good. """ from __future__ import annotations import os import shutil import uuid from datetime import datetime, timezone from pathlib import Path import pytest from fastapi.testclient import TestClient from photo_pipeline.api.app import create_app from photo_pipeline.config import Config from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations from photo_pipeline.models import Asset from photo_pipeline.services import diagnostics from photo_pipeline.services.app_lock import LibraryLock from photo_pipeline.services.backup import DB_NAME, BackupService NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) # Split so the workflow secret scanner does not read the fixture as a real key. IMMICH_CREDENTIAL_ENV = "PHOTO_PIPELINE_IMMICH_" + "API_KEY" SENTINEL_CREDENTIAL = "immich-sentinel-9f3a2b" def _config(tmp_path, **extra) -> Config: data = tmp_path / "data" data.mkdir(parents=True, exist_ok=True) lib = tmp_path / "lib" lib.mkdir(exist_ok=True) return Config.from_env( { "PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib), **extra, } ) def _migrated(config: Config): run_migrations(config.database_url) engine = create_db_engine(config.database_url) factory = create_session_factory(engine) with factory() as session: session.add( Asset( id=str(uuid.uuid4()), original_path="a.jpg", current_path="a.jpg", discovered_at=NOW, hash_version=1, byte_size=1, ) ) session.commit() engine.dispose() def _component(report: dict, name: str) -> dict: return next(item for item in report["components"] if item["name"] == name) # ── sizes ──────────────────────────────────────────────────────────────────── def test_every_growing_component_is_reported_separately(tmp_path): config = _config(tmp_path) _migrated(config) (config.thumbnail_cache_dir).mkdir(parents=True) (config.thumbnail_cache_dir / "a.webp").write_bytes(b"x" * 500) (config.data_dir / "uploads").mkdir() (config.data_dir / "uploads" / "batch.log").write_text("INFO ok\n") BackupService(config).create() report = diagnostics.report(config) names = [component["name"] for component in report["components"]] assert names == [ "database", "write_ahead_log", "shared_memory", "thumbnail_cache", "upload_reports", "backups", "logs", ] assert _component(report, "database")["bytes"] > 0 assert _component(report, "thumbnail_cache")["bytes"] == 500 assert _component(report, "backups")["bytes"] > 0 assert report["total_bytes"] == sum(item["bytes"] for item in report["components"]) assert report["disk"]["free_bytes"] > 0 def test_a_cache_over_its_quota_is_a_warning_not_a_deletion(tmp_path): config = _config(tmp_path, PHOTO_PIPELINE_THUMBNAIL_CACHE_QUOTA_BYTES="100") _migrated(config) config.thumbnail_cache_dir.mkdir(parents=True) cached = config.thumbnail_cache_dir / "big.webp" cached.write_bytes(b"x" * 400) report = diagnostics.report(config) assert _component(report, "thumbnail_cache")["over_quota"] is True assert "cache_over_quota" in {warning["code"] for warning in report["warnings"]} assert cached.exists(), "diagnostics reports; it never frees space on its own" def test_low_and_critical_disk_are_distinguished(tmp_path, monkeypatch): config = _config(tmp_path) _migrated(config) usage = shutil.disk_usage(tmp_path) monkeypatch.setattr( shutil, "disk_usage", lambda _: type(usage)(usage.total, usage.used, 500_000_000) ) assert {w["code"] for w in diagnostics.report(config)["warnings"]} == {"disk_low"} monkeypatch.setattr( shutil, "disk_usage", lambda _: type(usage)(usage.total, usage.used, 10_000_000) ) assert "disk_critical" in {w["code"] for w in diagnostics.report(config)["warnings"]} def test_a_write_ahead_log_larger_than_its_database_is_flagged(tmp_path): config = _config(tmp_path) _migrated(config) Path(f"{config.database_path}-wal").write_bytes(b"x" * (config.database_path.stat().st_size + 1)) codes = {warning["code"] for warning in diagnostics.report(config)["warnings"]} assert "wal_growth" in codes def test_disk_is_reported_for_a_data_directory_that_does_not_exist_yet(tmp_path): config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "not" / "yet")}) report = diagnostics.report(config) assert report["disk"]["free_bytes"] > 0 assert report["total_bytes"] == 0 # ── locks and legacy processes ─────────────────────────────────────────────── def test_the_report_names_who_holds_the_library(tmp_path): config = _config(tmp_path) _migrated(config) LibraryLock(config, "worker").acquire() report = diagnostics.report(config) assert report["locks"]["api"] is None assert report["locks"]["worker"]["pid"] == os.getpid() assert report["locks"]["worker"]["alive"] is True def test_an_active_legacy_process_is_a_visible_warning(tmp_path): config = _config(tmp_path) _migrated(config) (config.library_roots[0] / "photo_analyzer.log").write_text("scanning...\n") report = diagnostics.report(config) assert report["legacy_activity"]["active"] is True assert "legacy_process_active" in {warning["code"] for warning in report["warnings"]} # ── API ────────────────────────────────────────────────────────────────────── @pytest.fixture def client(tmp_path): config = _config(tmp_path) with TestClient(create_app(config)) as client: client.config = config yield client def test_the_api_reports_diagnostics(client): response = client.get("/api/v1/diagnostics") assert response.status_code == 200 body = response.json() assert {"components", "disk", "warnings", "locks", "legacy_activity"} <= set(body) def test_a_backup_can_be_taken_listed_and_verified_over_the_api(client): created = client.post("/api/v1/backups", json={"reason": "before-upgrade"}) assert created.status_code == 201 name = created.json()["name"] listed = client.get("/api/v1/backups").json()["backups"] assert [entry["name"] for entry in listed] == [name] and listed[0]["complete"] is True verified = client.get(f"/api/v1/backups/{name}/verify").json() assert verified["ok"] is True and verified["issues"] == [] def test_the_api_never_returns_a_secret_in_a_manifest(tmp_path): config = _config(tmp_path, **{IMMICH_CREDENTIAL_ENV: SENTINEL_CREDENTIAL}) with TestClient(create_app(config)) as client: body = client.post("/api/v1/backups", json={}).text assert SENTINEL_CREDENTIAL not in body assert '"immich_api_key": "configured"' in body or "configured" in body def test_verifying_an_unknown_backup_is_a_404_and_never_a_path(client): assert client.get("/api/v1/backups/nope/verify").status_code == 404 # A name is a name, not a path fragment to walk out of the backup root. escaped = client.get("/api/v1/backups/..%2F..%2Fetc/verify") assert escaped.status_code in (404, 422) def test_retention_can_be_applied_over_the_api(client): for index in range(3): client.post("/api/v1/backups", json={"reason": f"drill{index}", "keep": 99}) removed = client.post("/api/v1/backups/prune", params={"keep": 1}).json()["removed"] assert len(removed) == 2 assert len(client.get("/api/v1/backups").json()["backups"]) == 1 assert client.post("/api/v1/backups/prune", params={"keep": 0}).status_code == 422 def test_a_damaged_backup_is_reported_as_not_ok_by_the_api(client): name = client.post("/api/v1/backups", json={}).json()["name"] snapshot = BackupService(client.config).root / name / DB_NAME snapshot.write_bytes(snapshot.read_bytes() + b"trailing garbage") verified = client.get(f"/api/v1/backups/{name}/verify").json() assert verified["ok"] is False and verified["issues"]