CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / frontend-build (push) Successful in 26s
CI and images / extension-test (push) Successful in 20s
CI and images / integration (push) Failing after 2m22s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
pytest 9.2.0 (released 2026-10-27, changelog #10644) changed how monkeypatch.setattr restores an attribute the object does not hold in its own __dict__: undo now deletes it instead of assigning the old value back. Celery's conf serves every key through __getattr__, so undo called delattr, the delete raised AttributeError, and pytest skipped the rest of that test's undos. That made 53 teardown errors across six files (cleanup API, task-run signals, tasks admin, backup, library audit, the soft-limit salvage test), and could leak patches into later tests. CI installs pytest unpinned, so the first run after the release picked it up: the weekly base refresh of main, 2026-11-01, and every run since. The gate held, so nothing was published. tests/conftest.py gains `celery_conf(**values)`: it records each key's value through conf[key], writes with conf.update(), and restores the saved values after the test. The six files use it. Task #6864. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
7.0 KiB
Python
236 lines
7.0 KiB
Python
"""FC-3k: admin Celery task integration tests.
|
|
|
|
task_always_eager + signal handlers from FC-3i populate task_run.
|
|
We assert the wrapper passes args through correctly and that
|
|
task_run lifecycle status flips as expected.
|
|
"""
|
|
import pytest
|
|
from sqlalchemy import func, select
|
|
|
|
import backend.app.tasks.admin # noqa: F401 — register tasks
|
|
from backend.app.celery_app import celery
|
|
from backend.app.models import Artist, ImageRecord, TaskRun
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _eager_celery(celery_conf):
|
|
celery_conf(task_always_eager=True, task_eager_propagates=False)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fake_images_root(monkeypatch, tmp_path):
|
|
monkeypatch.setattr("backend.app.tasks.admin.IMAGES_ROOT", tmp_path)
|
|
|
|
|
|
# --- registration ----------------------------------------------------
|
|
|
|
|
|
def test_delete_artist_cascade_task_registered():
|
|
assert (
|
|
"backend.app.tasks.admin.delete_artist_cascade_task"
|
|
in celery.tasks
|
|
)
|
|
|
|
|
|
def test_bulk_delete_images_task_registered():
|
|
assert (
|
|
"backend.app.tasks.admin.bulk_delete_images_task"
|
|
in celery.tasks
|
|
)
|
|
|
|
|
|
def test_dedup_videos_task_registered():
|
|
assert "backend.app.tasks.admin.dedup_videos_task" in celery.tasks
|
|
|
|
|
|
# --- delete_artist_cascade_task -------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_artist_cascade_task_removes_artist_and_records_ok(
|
|
db_sync, tmp_path,
|
|
):
|
|
from backend.app.tasks.admin import delete_artist_cascade_task
|
|
|
|
a = Artist(name="Doomed", slug="doomed")
|
|
db_sync.add(a)
|
|
db_sync.flush()
|
|
for i in range(2):
|
|
f = tmp_path / f"d{i}.jpg"
|
|
f.write_bytes(b"x")
|
|
db_sync.add(ImageRecord(
|
|
artist_id=a.id, path=str(f),
|
|
sha256=f"{i:064x}", size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
))
|
|
db_sync.commit()
|
|
artist_id = a.id
|
|
|
|
result = delete_artist_cascade_task.delay(artist_id=artist_id).get()
|
|
assert result["summary"]["images_deleted"] == 2
|
|
|
|
surviving = db_sync.execute(
|
|
select(func.count(Artist.id)).where(Artist.id == artist_id)
|
|
).scalar_one()
|
|
assert surviving == 0
|
|
|
|
# FC-3i task_run lifecycle: task should have an 'ok' row.
|
|
status = db_sync.execute(
|
|
select(TaskRun.status)
|
|
.where(TaskRun.task_name.endswith(".delete_artist_cascade_task"))
|
|
.order_by(TaskRun.id.desc()).limit(1)
|
|
).scalar_one()
|
|
assert status == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_artist_cascade_task_records_failure(
|
|
db_sync, monkeypatch,
|
|
):
|
|
from backend.app.tasks.admin import delete_artist_cascade_task
|
|
|
|
def _boom(*a, **kw):
|
|
raise RuntimeError("synthetic cascade fail")
|
|
monkeypatch.setattr(
|
|
"backend.app.services.cleanup_service.delete_artist_cascade", _boom,
|
|
)
|
|
with pytest.raises(RuntimeError):
|
|
delete_artist_cascade_task.delay(artist_id=1).get()
|
|
|
|
row = db_sync.execute(
|
|
select(TaskRun.status, TaskRun.error_message)
|
|
.where(TaskRun.task_name.endswith(".delete_artist_cascade_task"))
|
|
.order_by(TaskRun.id.desc()).limit(1)
|
|
).one()
|
|
assert row.status == "error"
|
|
assert "synthetic" in (row.error_message or "")
|
|
|
|
|
|
# --- bulk_delete_images_task ----------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_images_task_removes_listed_images(
|
|
db_sync, tmp_path,
|
|
):
|
|
from backend.app.tasks.admin import bulk_delete_images_task
|
|
|
|
a = Artist(name="B", slug="b")
|
|
db_sync.add(a)
|
|
db_sync.flush()
|
|
ids = []
|
|
for i in range(3):
|
|
f = tmp_path / f"b{i}.jpg"
|
|
f.write_bytes(b"x")
|
|
img = ImageRecord(
|
|
artist_id=a.id, path=str(f),
|
|
sha256=f"a{i:063x}", size_bytes=10, mime="image/jpeg",
|
|
origin="imported_filesystem",
|
|
)
|
|
db_sync.add(img)
|
|
db_sync.flush()
|
|
ids.append(img.id)
|
|
db_sync.commit()
|
|
|
|
result = bulk_delete_images_task.delay(image_ids=ids).get()
|
|
assert result["images_deleted"] == 3
|
|
|
|
surviving = db_sync.execute(
|
|
select(func.count(ImageRecord.id))
|
|
.where(ImageRecord.id.in_(ids))
|
|
).scalar_one()
|
|
assert surviving == 0
|
|
|
|
|
|
# --- prune_missing_file_records_task (#859 orphan repair) -----------
|
|
|
|
|
|
def _img(db_sync, artist, *, path, sha):
|
|
img = ImageRecord(
|
|
artist_id=artist.id, path=str(path),
|
|
sha256=sha, size_bytes=10, mime="image/jpeg",
|
|
origin="downloaded",
|
|
)
|
|
db_sync.add(img)
|
|
db_sync.flush()
|
|
return img.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prune_missing_file_records_deletes_only_orphans(db_sync, tmp_path):
|
|
"""Records whose backing file is gone get deleted; records whose file is
|
|
present survive. Below the abort sample size, fraction is irrelevant."""
|
|
from backend.app.tasks.admin import prune_missing_file_records_task
|
|
|
|
a = Artist(name="Orph", slug="orph")
|
|
db_sync.add(a)
|
|
db_sync.flush()
|
|
|
|
present = []
|
|
for i in range(3):
|
|
f = tmp_path / f"present{i}.jpg"
|
|
f.write_bytes(b"real")
|
|
present.append(_img(db_sync, a, path=f, sha=f"{i:064x}"))
|
|
missing = [
|
|
_img(db_sync, a, path=tmp_path / f"gone{i}.jpg", sha=f"f{i:063x}")
|
|
for i in range(2)
|
|
]
|
|
db_sync.commit()
|
|
|
|
result = prune_missing_file_records_task.delay().get()
|
|
assert result["checked"] == 5
|
|
assert result["missing"] == 2
|
|
assert result["deleted"] == 2
|
|
|
|
db_sync.expire_all()
|
|
surviving = db_sync.execute(
|
|
select(ImageRecord.id).where(ImageRecord.id.in_(present + missing))
|
|
).scalars().all()
|
|
assert set(surviving) == set(present)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prune_missing_file_records_aborts_when_mostly_missing(db_sync, tmp_path):
|
|
"""NFS-stall guard: a large sample that's mostly missing means the
|
|
filesystem is unhealthy, not that the library evaporated — abort, delete
|
|
nothing."""
|
|
from backend.app.tasks.admin import prune_missing_file_records_task
|
|
|
|
a = Artist(name="Stall", slug="stall")
|
|
db_sync.add(a)
|
|
db_sync.flush()
|
|
ids = [
|
|
_img(db_sync, a, path=tmp_path / f"phantom{i}.jpg", sha=f"{i:064x}")
|
|
for i in range(55) # >= _ORPHAN_MIN_SAMPLE, all missing -> frac 1.0
|
|
]
|
|
db_sync.commit()
|
|
|
|
result = prune_missing_file_records_task.delay().get()
|
|
assert result["deleted"] == 0
|
|
assert "aborted" in result
|
|
|
|
db_sync.expire_all()
|
|
surviving = db_sync.execute(
|
|
select(func.count(ImageRecord.id)).where(ImageRecord.id.in_(ids))
|
|
).scalar_one()
|
|
assert surviving == 55
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_images_task_idempotent_on_empty_list(db_sync):
|
|
from backend.app.tasks.admin import bulk_delete_images_task
|
|
result = bulk_delete_images_task.delay(image_ids=[]).get()
|
|
assert result["images_deleted"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_delete_images_task_reports_missing_ids(db_sync):
|
|
from backend.app.tasks.admin import bulk_delete_images_task
|
|
result = bulk_delete_images_task.delay(
|
|
image_ids=[9_999_998, 9_999_999],
|
|
).get()
|
|
assert result["images_deleted"] == 0
|
|
assert sorted(result["missing_ids"]) == [9_999_998, 9_999_999]
|