PostAttachment's two FKs are both ON DELETE SET NULL, so a deleted post or artist left the row behind rather than taking it. Nothing ever pruned those rows, and nothing in the repo had ever unlinked a file under the attachment store — so both rows and bytes accumulated permanently, invisible to every existing diagnostic. Why a disk->DB reconciliation rather than a row sweep: the store is sha-addressed and idempotent, so ONE blob backs MANY rows. Deleting a row does not free its blob, and since the artist cascade (#3066) now deletes its attachment rows outright, a freed blob has no DB pointer left to find it by. Walking the store and asking "does any row still reference this sha?" catches orphans from every cause, including ones no future delete path will think to report. Preview and apply share `_orphan_attachment_conditions` (rule 93). The dry-run derives its surviving-sha set by NEGATING that same predicate, so it is honest about blobs the delete would free rather than counting them as still-referenced — the one place this was easy to get backwards, so it has its own parity test. Guards, each with a reason: - A blob is written before its row commits, so a just-stored file legitimately has no referencing row. Files under 6h are never judged — same guard and reasoning as ORPHAN_TEMP_MIN_AGE_HOURS. - `.partial` staging files belong to cleanup_orphaned_temp_files; skipped rather than raced. - The sha is parsed as the first 64 chars, not via Path.stem: store() takes the extension from the source filename, and a URL-encoded basename yields a multi-dot suffix that would make stem eat part of the sha. - A 900s walk budget reports partial=True instead of running to the task's hard limit (rule 89). - TASK_STUCK_THRESHOLD_MINUTES override at 30 (= time_limit 25 + 5). Without it a healthy 20-minute walk is phantom-flagged 'RecoverySweep' at the bare 5-min default — the #883 failure class; its invariant test is mirrored here. Defaults to the safe preview at both the task and the route, unlike the other maintenance triggers: this apply unlinks files. Operator-triggered only, never on a beat. Ships with its UI (rule 27): AttachmentReclaimCard in Cleanup → Duplicates & leftovers, built on the existing useMaintenanceTask/MaintenanceTile shapes, so a run survives navigating away. Surfaces files_failed and partial explicitly, since both change what the numbers mean. Also promotes humanBytes to utils/bytes.js — it was byte-identical in VideoDedupCard and GatedPurgeCard and this card would have been the third copy. The three divergent `formatBytes` helpers are deliberately left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
711 lines
22 KiB
Python
711 lines
22 KiB
Python
"""FC-3k: /api/admin/* endpoint integration tests.
|
|
|
|
Monkeypatch `.delay()` for Tier-C tasks to capture dispatch payloads
|
|
without actually queuing. Tier-B/A endpoints run synchronously
|
|
through the real service.
|
|
"""
|
|
import hashlib
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from backend.app.models import Artist, ImageRecord, Post, Tag, TagKind
|
|
from backend.app.models.tag import image_tag
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
# --- Tier-C: POST /artists/<slug>/cascade-delete --------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artist_cascade_dry_run_returns_projection(client, db):
|
|
a = Artist(name="Aria", slug="aria")
|
|
db.add(a)
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/artists/aria/cascade-delete",
|
|
json={"dry_run": True},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["artist"]["slug"] == "aria"
|
|
assert "projected" in body
|
|
assert body["projected"]["images"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artist_cascade_unknown_slug_404(client):
|
|
resp = await client.post(
|
|
"/api/admin/artists/no-such/cascade-delete",
|
|
json={"dry_run": True},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artist_cascade_wrong_confirm_400(client, db):
|
|
a = Artist(name="Aria", slug="aria")
|
|
db.add(a)
|
|
await db.commit()
|
|
artist_id = a.id
|
|
|
|
resp = await client.post(
|
|
"/api/admin/artists/aria/cascade-delete",
|
|
json={"dry_run": False, "confirm": "wrong"},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "confirm_mismatch"
|
|
assert body["expected"] == f"delete-artist-{artist_id}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_artist_cascade_correct_confirm_dispatches(
|
|
client, db, monkeypatch,
|
|
):
|
|
a = Artist(name="Aria", slug="aria")
|
|
db.add(a)
|
|
await db.commit()
|
|
artist_id = a.id
|
|
|
|
dispatched = []
|
|
|
|
class _Result:
|
|
id = "fake-task-id"
|
|
|
|
def _delay(**kw):
|
|
dispatched.append(kw)
|
|
return _Result()
|
|
|
|
monkeypatch.setattr(
|
|
"backend.app.tasks.admin.delete_artist_cascade_task.delay", _delay,
|
|
)
|
|
|
|
resp = await client.post(
|
|
"/api/admin/artists/aria/cascade-delete",
|
|
json={
|
|
"dry_run": False,
|
|
"confirm": f"delete-artist-{artist_id}",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
body = await resp.get_json()
|
|
assert body["task_id"] == "fake-task-id"
|
|
assert dispatched == [{"artist_id": artist_id}]
|
|
|
|
|
|
# --- Tier-C: POST /images/bulk-delete -------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_dry_run_returns_counts(client, db, tmp_path):
|
|
a = Artist(name="B", slug="b")
|
|
db.add(a)
|
|
await db.flush()
|
|
i1 = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "1.jpg"),
|
|
sha256="1" * 64, size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
i2 = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "2.jpg"),
|
|
sha256="2" * 64, size_bytes=20, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db.add_all([i1, i2])
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={
|
|
"image_ids": [i1.id, i2.id, 9_999_999],
|
|
"dry_run": True,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["images_found"] == 2
|
|
assert body["bytes_on_disk"] == 30
|
|
assert body["missing_ids"] == [9_999_999]
|
|
# Dry-run hands the canonical Tier-C confirm token back so the
|
|
# frontend doesn't recompute SHA-256 client-side (crypto.subtle
|
|
# is Secure-Context-gated; FC runs over plain HTTP).
|
|
assert body["confirm_token"].startswith("delete-images-")
|
|
assert len(body["confirm_token"]) == len("delete-images-") + 8
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_empty_list_400(client):
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={"image_ids": [], "dry_run": True},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "invalid_image_ids"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_non_int_400(client):
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={"image_ids": ["foo", 2], "dry_run": True},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_wrong_confirm_400_with_expected_token(
|
|
client, db, tmp_path,
|
|
):
|
|
a = Artist(name="B", slug="b")
|
|
db.add(a)
|
|
await db.flush()
|
|
img = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "x.jpg"),
|
|
sha256="c" * 64, size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db.add(img)
|
|
await db.commit()
|
|
img_id = img.id
|
|
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={
|
|
"image_ids": [img_id], "dry_run": False, "confirm": "nope",
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "confirm_mismatch"
|
|
assert body["expected"].startswith("delete-images-")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_correct_confirm_dispatches(
|
|
client, db, tmp_path, monkeypatch,
|
|
):
|
|
a = Artist(name="B", slug="b")
|
|
db.add(a)
|
|
await db.flush()
|
|
img = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "x.jpg"),
|
|
sha256="d" * 64, size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db.add(img)
|
|
await db.commit()
|
|
img_id = img.id
|
|
|
|
sha8 = hashlib.sha256(str(img_id).encode("utf-8")).hexdigest()[:8]
|
|
|
|
dispatched = []
|
|
|
|
class _Result:
|
|
id = "fake-bulk-id"
|
|
|
|
def _delay(**kw):
|
|
dispatched.append(kw)
|
|
return _Result()
|
|
|
|
monkeypatch.setattr(
|
|
"backend.app.tasks.admin.bulk_delete_images_task.delay", _delay,
|
|
)
|
|
|
|
resp = await client.post(
|
|
"/api/admin/images/bulk-delete",
|
|
json={
|
|
"image_ids": [img_id],
|
|
"dry_run": False,
|
|
"confirm": f"delete-images-{sha8}",
|
|
},
|
|
)
|
|
assert resp.status_code == 202
|
|
body = await resp.get_json()
|
|
assert body["task_id"] == "fake-bulk-id"
|
|
assert dispatched == [{"image_ids": [img_id]}]
|
|
|
|
|
|
# --- Tier-B: DELETE /tags/<id> + POST /tags/<dest>/merge -----------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_delete_200_after_cascade(client, db):
|
|
t = Tag(name="doomed-tag", kind=TagKind.general)
|
|
db.add(t)
|
|
await db.commit()
|
|
tag_id = t.id
|
|
|
|
resp = await client.delete(f"/api/admin/tags/{tag_id}")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["deleted"]["id"] == tag_id
|
|
assert body["associations_removed"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_delete_404_unknown(client):
|
|
resp = await client.delete("/api/admin/tags/9999999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_merge_succeeds_for_same_kind(client, db):
|
|
src = Tag(name="src-tag", kind=TagKind.general)
|
|
dest = Tag(name="dest-tag", kind=TagKind.general)
|
|
db.add_all([src, dest])
|
|
await db.commit()
|
|
src_id = src.id
|
|
dest_id = dest.id
|
|
|
|
resp = await client.post(
|
|
f"/api/admin/tags/{dest_id}/merge",
|
|
json={"source_id": src_id},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["result"]["target_id"] == dest_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_merge_dry_run_previews_without_mutating(client, db):
|
|
from backend.app.models import ImageRecord
|
|
from backend.app.models.tag import image_tag
|
|
|
|
src = Tag(name="dry-src", kind=TagKind.general)
|
|
dest = Tag(name="dry-dest", kind=TagKind.general)
|
|
db.add_all([src, dest])
|
|
await db.flush()
|
|
img = ImageRecord(
|
|
path="/images/dry.jpg", sha256="dry" + "0" * 61, size_bytes=1,
|
|
mime="image/jpeg", width=1, height=1,
|
|
origin="imported_filesystem", integrity_status="unknown",
|
|
)
|
|
db.add(img)
|
|
await db.flush()
|
|
await db.execute(image_tag.insert().values(
|
|
image_record_id=img.id, tag_id=src.id, source="manual",
|
|
))
|
|
src_id, dest_id = src.id, dest.id
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
f"/api/admin/tags/{dest_id}/merge",
|
|
json={"source_id": src_id, "dry_run": True},
|
|
)
|
|
assert resp.status_code == 200
|
|
p = (await resp.get_json())["preview"]
|
|
assert p["compatible"] is True
|
|
assert p["images_moving"] == 1
|
|
assert p["source_total"] == 1
|
|
# No mutation: source still exists.
|
|
assert await db.get(Tag, src_id) is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_merge_rejects_self_merge(client, db):
|
|
t = Tag(name="x", kind=TagKind.general)
|
|
db.add(t)
|
|
await db.commit()
|
|
resp = await client.post(
|
|
f"/api/admin/tags/{t.id}/merge",
|
|
json={"source_id": t.id},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_merge_returns_400_on_kind_mismatch(client, db):
|
|
src = Tag(name="src", kind=TagKind.general)
|
|
dest = Tag(name="dest", kind=TagKind.artist)
|
|
db.add_all([src, dest])
|
|
await db.commit()
|
|
resp = await client.post(
|
|
f"/api/admin/tags/{dest.id}/merge",
|
|
json={"source_id": src.id},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "tag_kind_mismatch"
|
|
|
|
|
|
# --- Tier-B helper: GET /tags/<id>/usage-count ----------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tag_usage_count_returns_zero_for_unused(client, db):
|
|
t = Tag(name="lonely", kind=TagKind.general)
|
|
db.add(t)
|
|
await db.commit()
|
|
resp = await client.get(f"/api/admin/tags/{t.id}/usage-count")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["count"] == 0
|
|
|
|
|
|
# --- Tier-A: POST /tags/prune-unused --------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prune_unused_dry_run_lists_unused(client, db, tmp_path):
|
|
a = Artist(name="P", slug="p")
|
|
db.add(a)
|
|
await db.flush()
|
|
img = ImageRecord(
|
|
artist_id=a.id, path=str(tmp_path / "p.jpg"),
|
|
sha256="e" * 64, size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db.add(img)
|
|
used = Tag(name="kept", kind=TagKind.general)
|
|
unused = Tag(name="prune", kind=TagKind.general)
|
|
db.add_all([used, unused])
|
|
await db.flush()
|
|
await db.execute(image_tag.insert().values(
|
|
image_record_id=img.id, tag_id=used.id,
|
|
))
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/tags/prune-unused", json={"dry_run": True},
|
|
)
|
|
body = await resp.get_json()
|
|
assert body["count"] == 1
|
|
assert "prune" in body["sample_names"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prune_unused_commit_deletes_and_returns_count(client, db):
|
|
db.add(Tag(name="byebye", kind=TagKind.general))
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/tags/prune-unused", json={"dry_run": False},
|
|
)
|
|
body = await resp.get_json()
|
|
assert body["deleted"] >= 1
|
|
assert "byebye" in body["sample_names"]
|
|
|
|
|
|
# --- Tier-A: POST /posts/refetch-external ----------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refetch_external_resets_and_dispatches(client, db, monkeypatch):
|
|
"""Surgical recovery for deep posts (2026-07-03): settled links reset to
|
|
pending with a fresh budget + dispatched; in-flight links untouched."""
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.models import ExternalLink, Post, Source
|
|
|
|
a = Artist(name="Deep", slug="deep")
|
|
db.add(a)
|
|
await db.flush()
|
|
src = Source(artist_id=a.id, platform="patreon",
|
|
url="https://www.patreon.com/deep", enabled=True)
|
|
db.add(src)
|
|
await db.flush()
|
|
post = Post(artist_id=a.id, source_id=src.id, external_post_id="96376640")
|
|
db.add(post)
|
|
await db.flush()
|
|
done = ExternalLink(post_id=post.id, artist_id=a.id, host="mega",
|
|
url="https://mega.nz/file/a#k1", status="downloaded",
|
|
attempts=1)
|
|
dead = ExternalLink(post_id=post.id, artist_id=a.id, host="gdrive",
|
|
url="https://drive.google.com/x", status="dead",
|
|
attempts=5, last_error="quota")
|
|
busy = ExternalLink(post_id=post.id, artist_id=a.id, host="mega",
|
|
url="https://mega.nz/file/b#k2", status="downloading",
|
|
attempts=1)
|
|
db.add_all([done, dead, busy])
|
|
await db.flush()
|
|
ids = (done.id, dead.id, busy.id)
|
|
await db.commit()
|
|
|
|
fetches = []
|
|
monkeypatch.setattr(
|
|
"backend.app.tasks.external.fetch_external_link.delay",
|
|
lambda lid: fetches.append(lid),
|
|
)
|
|
|
|
resp = await client.post(
|
|
"/api/admin/posts/refetch-external",
|
|
json={"external_post_id": "96376640"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert list(body["posts"].values()) == [{"links_total": 3, "links_reset": 2}]
|
|
assert sorted(fetches) == sorted([ids[0], ids[1]])
|
|
|
|
rows = dict((await db.execute(
|
|
select(ExternalLink.id, ExternalLink.status)
|
|
.where(ExternalLink.post_id == post.id)
|
|
)).all())
|
|
assert rows[ids[0]] == "pending"
|
|
assert rows[ids[1]] == "pending"
|
|
assert rows[ids[2]] == "downloading" # in-flight left alone
|
|
|
|
# Unknown post → 404, nothing dispatched.
|
|
miss = await client.post(
|
|
"/api/admin/posts/refetch-external",
|
|
json={"external_post_id": "nope"},
|
|
)
|
|
assert miss.status_code == 404
|
|
|
|
|
|
# --- Tier-A: POST /posts/prune-bare + /posts/reconcile-duplicates ---
|
|
# These two routes share _run_dry_run_op with the tag prunes (DRY pass,
|
|
# task #753); cover their apply path + reconcile's source_id passthrough so
|
|
# every helper consumer is exercised at the route level, not just the service.
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prune_bare_commit_deletes_bare_posts(client, db):
|
|
from sqlalchemy import func, select
|
|
|
|
a = Artist(name="BP", slug="bp-api")
|
|
db.add(a)
|
|
await db.flush()
|
|
db.add(Post(artist_id=a.id, external_post_id="bare1")) # no images/attachments
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/posts/prune-bare", json={"dry_run": False},
|
|
)
|
|
body = await resp.get_json()
|
|
assert body["deleted"] >= 1
|
|
remaining = (await db.execute(
|
|
select(func.count()).select_from(Post).where(Post.external_post_id == "bare1")
|
|
)).scalar_one()
|
|
assert remaining == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconcile_duplicates_commit_merges_via_endpoint(client, db):
|
|
from sqlalchemy import select
|
|
|
|
a = Artist(name="RC", slug="rc-api")
|
|
db.add(a)
|
|
await db.flush()
|
|
db.add_all([
|
|
Post(artist_id=a.id, external_post_id="711509",
|
|
raw_metadata={"post_id": 1923726, "category": "subscribestar"},
|
|
description="body"),
|
|
Post(artist_id=a.id, external_post_id="1923726",
|
|
raw_metadata={"post_id": 1923726, "category": "subscribestar"}),
|
|
])
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/admin/posts/reconcile-duplicates", json={"dry_run": False},
|
|
)
|
|
body = await resp.get_json()
|
|
assert body["merged"] == 1
|
|
rows = (await db.execute(select(Post.external_post_id))).scalars().all()
|
|
assert rows == ["1923726"] # one post-id-keyed keeper survives
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconcile_duplicates_rejects_bad_source_id(client):
|
|
resp = await client.post(
|
|
"/api/admin/posts/reconcile-duplicates",
|
|
json={"dry_run": True, "source_id": "abc"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
# --- DB maintenance: bloat readout + manual VACUUM trigger ----------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_stats_returns_table_bloat_shape(client):
|
|
resp = await client.get("/api/admin/maintenance/db-stats")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert "tables" in body
|
|
names = {t["table"] for t in body["tables"]}
|
|
assert "image_record" in names
|
|
for t in body["tables"]:
|
|
assert {"table", "live", "dead", "dead_pct", "last_vacuum"} <= set(t)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_vacuum_queues_the_task(client, monkeypatch):
|
|
from backend.app.tasks import maintenance
|
|
|
|
calls = []
|
|
monkeypatch.setattr(maintenance.vacuum_analyze, "delay", lambda: calls.append(1))
|
|
resp = await client.post("/api/admin/maintenance/vacuum")
|
|
assert resp.status_code == 202
|
|
assert calls == [1]
|
|
|
|
|
|
# --- maintenance triggers route through _queued() (DRY Finding B, #753) ---
|
|
# Each operator-triggered task returns 202 + the Celery task id via the shared
|
|
# _queued() helper. These trigger endpoints had no route-level tests; cover every
|
|
# helper consumer, and assert the dry_run flag threads through where applicable.
|
|
|
|
|
|
def _fake_delay(captured):
|
|
def _delay(*args, **kwargs):
|
|
captured.append((args, kwargs))
|
|
return SimpleNamespace(id="task-xyz")
|
|
return _delay
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_reextract_archives_queues(client, monkeypatch):
|
|
from backend.app.tasks import admin as admin_tasks
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
admin_tasks.reextract_archive_attachments_task, "delay", _fake_delay(calls)
|
|
)
|
|
resp = await client.post("/api/admin/maintenance/reextract-archives")
|
|
assert resp.status_code == 202
|
|
assert (await resp.get_json())["task_id"] == "task-xyz"
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_prune_missing_files_queues(client, monkeypatch):
|
|
from backend.app.tasks import admin as admin_tasks
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
admin_tasks.prune_missing_file_records_task, "delay", _fake_delay(calls)
|
|
)
|
|
resp = await client.post("/api/admin/maintenance/prune-missing-files")
|
|
assert resp.status_code == 202
|
|
assert (await resp.get_json())["task_id"] == "task-xyz"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_dedup_videos_queues_and_threads_dry_run(client, monkeypatch):
|
|
from backend.app.tasks import admin as admin_tasks
|
|
|
|
calls = []
|
|
monkeypatch.setattr(admin_tasks.dedup_videos_task, "delay", _fake_delay(calls))
|
|
resp = await client.post(
|
|
"/api/admin/maintenance/dedup-videos", json={"dry_run": False},
|
|
)
|
|
assert resp.status_code == 202
|
|
assert (await resp.get_json())["task_id"] == "task-xyz"
|
|
assert calls[0][1] == {"dry_run": False} # flag threaded to the task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_purge_gated_previews_queues_and_threads_dry_run(client, monkeypatch):
|
|
from backend.app.tasks import admin as admin_tasks
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
admin_tasks.purge_gated_previews_task, "delay", _fake_delay(calls)
|
|
)
|
|
resp = await client.post(
|
|
"/api/admin/maintenance/purge-gated-previews", json={"dry_run": True},
|
|
)
|
|
assert resp.status_code == 202
|
|
assert (await resp.get_json())["task_id"] == "task-xyz"
|
|
assert calls[0][1] == {"dry_run": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_normalize_live_queues(client, monkeypatch):
|
|
from backend.app.tasks import admin as admin_tasks
|
|
|
|
calls = []
|
|
monkeypatch.setattr(admin_tasks.normalize_tags_task, "delay", _fake_delay(calls))
|
|
resp = await client.post("/api/admin/tags/normalize", json={"dry_run": False})
|
|
assert resp.status_code == 202
|
|
assert (await resp.get_json())["task_id"] == "task-xyz"
|
|
|
|
|
|
# --- Tier-A: POST /tags/reset-content -------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_content_tagging_dry_run_returns_counts_and_token(client, db):
|
|
db.add_all([
|
|
Tag(name="solo", kind=TagKind.general),
|
|
Tag(name="naruto", kind=TagKind.character),
|
|
Tag(name="Naruto", kind=TagKind.fandom),
|
|
Tag(name="my-series", kind=TagKind.series),
|
|
])
|
|
await db.commit()
|
|
resp = await client.post(
|
|
"/api/admin/tags/reset-content", json={"dry_run": True}
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["count"] == 2
|
|
assert body["by_kind"] == {"general": 1, "character": 1}
|
|
# dry-run leaves the rows in place — fandom + series untouched too.
|
|
assert "deleted" not in body
|
|
# The preview arms the apply: an 8-hex confirm token over the live counts.
|
|
assert len(body["confirm"]) == 8
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_content_tagging_apply_requires_confirm_token(client, db):
|
|
from sqlalchemy import func, select
|
|
|
|
db.add(Tag(name="solo", kind=TagKind.general))
|
|
await db.commit()
|
|
|
|
# No token / wrong token → rejected, nothing deleted.
|
|
for bad in ({"dry_run": False}, {"dry_run": False, "confirm": "deadbeef"}):
|
|
resp = await client.post("/api/admin/tags/reset-content", json=bad)
|
|
assert resp.status_code == 400
|
|
# Non-system only: the seeded hygiene tags (#128) survive a reset by
|
|
# design, so they'd inflate a whole-table count.
|
|
remaining = (await db.execute(
|
|
select(func.count()).select_from(Tag).where(Tag.is_system.is_(False))
|
|
)).scalar_one()
|
|
assert remaining == 1
|
|
|
|
# The token from a fresh preview unlocks the apply.
|
|
token = (await (await client.post(
|
|
"/api/admin/tags/reset-content", json={"dry_run": True}
|
|
)).get_json())["confirm"]
|
|
resp = await client.post(
|
|
"/api/admin/tags/reset-content",
|
|
json={"dry_run": False, "confirm": token},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert (await resp.get_json())["deleted"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_reclaim_attachments_defaults_to_preview(client, monkeypatch):
|
|
"""Unlike the other maintenance triggers, this one's apply unlinks FILES —
|
|
so an empty body must mean preview, not apply."""
|
|
from backend.app.tasks import admin as admin_tasks
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
admin_tasks.reclaim_orphaned_attachments_task, "delay", _fake_delay(calls)
|
|
)
|
|
resp = await client.post("/api/admin/maintenance/reclaim-attachments", json={})
|
|
assert resp.status_code == 202
|
|
assert (await resp.get_json())["task_id"] == "task-xyz"
|
|
assert calls[0][1] == {"dry_run": True}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_reclaim_attachments_threads_apply(client, monkeypatch):
|
|
from backend.app.tasks import admin as admin_tasks
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
admin_tasks.reclaim_orphaned_attachments_task, "delay", _fake_delay(calls)
|
|
)
|
|
resp = await client.post(
|
|
"/api/admin/maintenance/reclaim-attachments", json={"dry_run": False},
|
|
)
|
|
assert resp.status_code == 202
|
|
assert calls[0][1] == {"dry_run": False}
|