"""API tests for the /api/cleanup/* blueprint. Per reference-async-coredml-test-assertions, post-DML state checks go via column selects, not ORM entity access. """ import hashlib from datetime import UTC, datetime import pytest from PIL import Image from sqlalchemy import func, select from backend.app.celery_app import celery from backend.app.models import ImageRecord, LibraryAuditRun pytestmark = pytest.mark.integration @pytest.fixture(autouse=True) def disable_celery_eager(monkeypatch): monkeypatch.setattr(celery.conf, "task_always_eager", False) def _sha256_min_dim_token(min_w: int, min_h: int) -> str: canon = f"{min_w}x{min_h}" return f"delete-min-dim-{hashlib.sha256(canon.encode()).hexdigest()[:8]}" async def _seed_image(db, tmp_path, *, w, h, name): path = tmp_path / name Image.new("RGB", (w, h), (w % 256, h % 256, 0)).save(path) sha = f"api-cleanup-{name}".ljust(64, "x")[:64] rec = ImageRecord( path=str(path), sha256=sha, size_bytes=path.stat().st_size, mime="image/png", width=w, height=h, origin="imported_filesystem", integrity_status="ok", ) db.add(rec) await db.flush() return rec @pytest.mark.asyncio async def test_min_dimension_preview_returns_count(client, db, tmp_path): await _seed_image(db, tmp_path, w=50, h=50, name="small.png") await _seed_image(db, tmp_path, w=500, h=500, name="big.png") await db.commit() resp = await client.post( "/api/cleanup/min-dimension/preview", json={"min_width": 200, "min_height": 200}, ) assert resp.status_code == 200 body = await resp.get_json() assert body["count"] == 1 # Preview hands the canonical Tier-C confirm token back so the # frontend doesn't have to recompute SHA-256 client-side # (crypto.subtle is Secure-Context-gated; FC runs over plain HTTP). assert body["confirm_token"] == _sha256_min_dim_token(200, 200) @pytest.mark.asyncio async def test_min_dimension_delete_with_token_removes_rows(client, db, tmp_path): await _seed_image(db, tmp_path, w=50, h=50, name="s2.png") await _seed_image(db, tmp_path, w=500, h=500, name="b2.png") await db.commit() token = _sha256_min_dim_token(200, 200) resp = await client.post( "/api/cleanup/min-dimension/delete", json={"min_width": 200, "min_height": 200, "confirm": token}, ) assert resp.status_code == 200 body = await resp.get_json() assert body["deleted"] == 1 remaining = await db.execute(select(func.count()).select_from(ImageRecord)) assert remaining.scalar_one() == 1 @pytest.mark.asyncio async def test_min_dimension_delete_with_bad_token_returns_400(client, db, tmp_path): await _seed_image(db, tmp_path, w=50, h=50, name="s3.png") await db.commit() resp = await client.post( "/api/cleanup/min-dimension/delete", json={"min_width": 200, "min_height": 200, "confirm": "nope"}, ) assert resp.status_code == 400 body = await resp.get_json() assert body["error"] == "confirm_mismatch" @pytest.mark.asyncio async def test_audit_create_returns_id_and_running_status( client, db, monkeypatch, ): from backend.app.tasks import library_audit monkeypatch.setattr( library_audit.scan_library_for_rule, "delay", lambda audit_id: None, ) resp = await client.post( "/api/cleanup/audit", json={"rule": "transparency", "params": {"threshold": 0.9}}, ) assert resp.status_code == 202 body = await resp.get_json() assert body["status"] == "running" assert isinstance(body["audit_id"], int) @pytest.mark.asyncio async def test_audit_create_returns_409_when_another_is_running(client, db): audit = LibraryAuditRun( rule="transparency", params={"threshold": 0.9}, status="running", matched_ids=[], ) db.add(audit) await db.commit() resp = await client.post( "/api/cleanup/audit", json={"rule": "single_color", "params": {"threshold": 0.95, "tolerance": 30}}, ) assert resp.status_code == 409 @pytest.mark.asyncio async def test_audit_get_by_id_returns_full_row(client, db): audit = LibraryAuditRun( rule="transparency", params={"threshold": 0.85}, status="ready", scanned_count=100, matched_count=3, matched_ids=[1, 2, 3], ) db.add(audit) await db.commit() resp = await client.get(f"/api/cleanup/audit/{audit.id}") assert resp.status_code == 200 body = await resp.get_json() assert body["rule"] == "transparency" assert body["matched_count"] == 3 assert body["matched_ids"] == [1, 2, 3] @pytest.mark.asyncio async def test_audit_history_returns_recent_runs(client, db): for _ in range(3): db.add(LibraryAuditRun( rule="transparency", params={"threshold": 0.9}, status="applied", matched_ids=[], finished_at=datetime.now(UTC), )) await db.commit() resp = await client.get("/api/cleanup/audit?limit=5") assert resp.status_code == 200 body = await resp.get_json() assert len(body["runs"]) >= 3 @pytest.mark.asyncio async def test_audit_history_filters_by_rule(client, db): db.add(LibraryAuditRun( rule="transparency", params={"threshold": 0.9}, status="applied", matched_ids=[], finished_at=datetime.now(UTC), )) db.add(LibraryAuditRun( rule="single_color", params={"threshold": 0.95, "tolerance": 30}, status="ready", matched_count=2, matched_ids=[1, 2], finished_at=datetime.now(UTC), )) await db.commit() # ?rule=&limit=1 → just THIS rule's latest run (the card-reconnect query). resp = await client.get("/api/cleanup/audit?rule=single_color&limit=1") assert resp.status_code == 200 runs = (await resp.get_json())["runs"] assert len(runs) == 1 assert runs[0]["rule"] == "single_color" assert runs[0]["matched_count"] == 2 @pytest.mark.asyncio async def test_audit_apply_with_token_deletes(client, db, tmp_path): rec = await _seed_image(db, tmp_path, w=100, h=100, name="apply.png") audit = LibraryAuditRun( rule="transparency", params={"threshold": 0.9}, status="ready", scanned_count=1, matched_count=1, matched_ids=[rec.id], ) db.add(audit) await db.commit() resp = await client.post( f"/api/cleanup/audit/{audit.id}/apply", json={"confirm": f"delete-audit-{audit.id}"}, ) assert resp.status_code == 200 body = await resp.get_json() assert body["deleted"] == 1 @pytest.mark.asyncio async def test_audit_apply_with_bad_token_returns_400(client, db): audit = LibraryAuditRun( rule="transparency", params={"threshold": 0.9}, status="ready", matched_ids=[], ) db.add(audit) await db.commit() resp = await client.post( f"/api/cleanup/audit/{audit.id}/apply", json={"confirm": "wrong-token"}, ) assert resp.status_code == 400 body = await resp.get_json() assert body["error"] == "confirm_mismatch" @pytest.mark.asyncio async def test_audit_cancel_flips_status(client, db): audit = LibraryAuditRun( rule="transparency", params={"threshold": 0.9}, status="running", matched_ids=[], ) db.add(audit) await db.commit() resp = await client.post(f"/api/cleanup/audit/{audit.id}/cancel") assert resp.status_code == 200 new_status = await db.execute( select(LibraryAuditRun.status).where(LibraryAuditRun.id == audit.id) ) assert new_status.scalar_one() == "cancelled"