"""FC-3d: tick_due_sources task tests. Verifies (a) due sources are queued, (b) sources with a pending/running DownloadEvent are skipped, (c) the tick is read-only on Source itself (no last_checked_at write), and (d) the result dict shape. """ import pytest from sqlalchemy import select import backend.app.tasks.scan # noqa: F401 — side-effect: register celery task from backend.app.celery_app import celery from backend.app.models import Artist, DownloadEvent, Source pytestmark = pytest.mark.integration class _FakeTask: """Stand-in for the real Celery Task object — only `.delay()` matters.""" def __init__(self, sink: list): self._sink = sink def delay(self, source_id: int): self._sink.append(source_id) @pytest.mark.asyncio async def test_tick_returns_counts_dict_when_no_due_sources(db, monkeypatch): """Conftest TRUNCATEs after every integration test, so we start clean.""" delayed: list[int] = [] monkeypatch.setattr( "backend.app.tasks.download.download_source", _FakeTask(delayed), ) from backend.app.tasks.scan import _tick_due_sources_async result = await _tick_due_sources_async() assert set(result.keys()) == {"due", "queued", "skipped_in_flight", "tick_at"} assert result["due"] == 0 assert result["queued"] == 0 assert result["skipped_in_flight"] == 0 assert delayed == [] @pytest.mark.asyncio async def test_tick_queues_due_source(db, monkeypatch): artist = Artist(name="tick-q", slug="tick-q", auto_check=True) db.add(artist) await db.flush() src = Source( artist_id=artist.id, platform="patreon", url="https://tick-q", enabled=True, consecutive_failures=0, ) db.add(src) await db.commit() delayed: list[int] = [] monkeypatch.setattr( "backend.app.tasks.download.download_source", _FakeTask(delayed), ) from backend.app.tasks.scan import _tick_due_sources_async result = await _tick_due_sources_async() assert result["queued"] >= 1 assert src.id in delayed pending_count = (await db.execute( select(DownloadEvent.id).where( DownloadEvent.source_id == src.id, DownloadEvent.status == "pending", ) )).scalars().all() assert len(pending_count) == 1 @pytest.mark.asyncio async def test_tick_skips_in_flight_source(db, monkeypatch): artist = Artist(name="tick-inf", slug="tick-inf", auto_check=True) db.add(artist) await db.flush() src = Source( artist_id=artist.id, platform="patreon", url="https://tick-inf", enabled=True, consecutive_failures=0, ) db.add(src) await db.flush() db.add(DownloadEvent(source_id=src.id, status="running")) await db.commit() delayed: list[int] = [] monkeypatch.setattr( "backend.app.tasks.download.download_source", _FakeTask(delayed), ) from backend.app.tasks.scan import _tick_due_sources_async result = await _tick_due_sources_async() assert src.id not in delayed assert result["skipped_in_flight"] >= 1 @pytest.mark.asyncio async def test_tick_does_not_modify_source_last_checked_at(db, monkeypatch): """The tick creates DownloadEvent(pending) but never writes Source columns. Source.last_checked_at is owned by DownloadService.finalize. """ artist = Artist(name="tick-ro", slug="tick-ro", auto_check=True) db.add(artist) await db.flush() src = Source( artist_id=artist.id, platform="patreon", url="https://tick-ro", enabled=True, consecutive_failures=0, ) db.add(src) await db.commit() src_id = src.id before = (await db.execute( select(Source.last_checked_at).where(Source.id == src_id) )).scalar_one() monkeypatch.setattr( "backend.app.tasks.download.download_source", _FakeTask([]), ) from backend.app.tasks.scan import _tick_due_sources_async await _tick_due_sources_async() after = (await db.execute( select(Source.last_checked_at).where(Source.id == src_id) )).scalar_one() assert before == after # still None / unchanged def test_tick_task_is_registered(): """Side-effect import at the top of this test file forces task module load.""" assert "backend.app.tasks.scan.tick_due_sources" in celery.tasks