CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 34s
CI and images / integration (push) Successful in 2m18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
A download writes its files into the artist's folder and imports them in a later step. A run that stopped in between left complete files no record points at, and a walk that had already marked them seen never returned for them; on the live instance that is about 95 GB of art. "Import files the library is missing" (Cleanup, Duplicates & leftovers) walks every folder named for an artist, matched by slug so a differently cased folder still matches, and lists the images, videos and archives no image, attachment or queued import points at. Stores, sidecars, staging files and files changed in the last day are left alone. Preview shows the counts per artist; Import queues the same list as a library import batch, whose tasks file the folder's post record, attach each file where it lies with its folder's artist and source, and remove the ones the library already holds or the import filters reject. A file whose import crashed is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
730 lines
23 KiB
Python
730 lines
23 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}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_import_unimported_defaults_to_preview(client, monkeypatch):
|
|
"""An empty body previews: the apply removes files the library rejects."""
|
|
from backend.app.tasks import admin as admin_tasks
|
|
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
admin_tasks.import_unimported_files_task, "delay", _fake_delay(calls)
|
|
)
|
|
resp = await client.post("/api/admin/maintenance/import-unimported", json={})
|
|
assert resp.status_code == 202
|
|
assert calls[0][1] == {"dry_run": True}
|
|
|
|
resp = await client.post(
|
|
"/api/admin/maintenance/import-unimported", json={"dry_run": False},
|
|
)
|
|
assert calls[1][1] == {"dry_run": False}
|