Files
NtR-soudcloud-fetcher/tests/test_poller.py
cottongin cb3ae403cf feat: add historical backfill with --init CLI and episode numbering
Adds a --init mode that seeds the database with past shows from a given
anchor episode/date forward, batch-fetching likes from SoundCloud and
partitioning them into weekly buckets. Episode numbers are tracked in
the shows table and auto-incremented by the poller for new shows.

Includes full API documentation (docs/api.md) and updated README.

Made-with: Cursor
2026-03-12 02:09:15 -04:00

237 lines
5.9 KiB
Python

import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
from ntr_fetcher.poller import Poller
from ntr_fetcher.models import Track
def _make_track(id: int, liked_at: str) -> Track:
return Track(
id=id,
title=f"Track {id}",
artist="Artist",
permalink_url=f"https://soundcloud.com/a/t-{id}",
artwork_url=None,
duration_ms=180000,
license="cc-by",
liked_at=datetime.fromisoformat(liked_at),
raw_json="{}",
)
@pytest.mark.asyncio
async def test_poll_once_fetches_and_stores():
mock_sc = AsyncMock()
mock_sc.resolve_user.return_value = 206979918
mock_sc.fetch_likes.return_value = [
_make_track(1, "2026-03-14T01:00:00+00:00"),
_make_track(2, "2026-03-14T02:00:00+00:00"),
]
mock_db = MagicMock()
mock_show = MagicMock()
mock_show.id = 1
mock_db.get_or_create_show.return_value = mock_show
poller = Poller(
db=mock_db,
soundcloud=mock_sc,
soundcloud_user="nicktherat",
show_day=2,
show_hour=22,
poll_interval=3600,
)
await poller.poll_once()
assert mock_sc.resolve_user.called
assert mock_sc.fetch_likes.called
assert mock_db.upsert_track.call_count == 2
assert mock_db.set_show_tracks.called
call_args = mock_db.set_show_tracks.call_args
assert call_args[0][0] == 1
assert call_args[0][1] == [1, 2]
@pytest.mark.asyncio
async def test_poll_once_removes_unliked_tracks():
mock_sc = AsyncMock()
mock_sc.resolve_user.return_value = 206979918
mock_sc.fetch_likes.return_value = [
_make_track(1, "2026-03-14T01:00:00+00:00"),
_make_track(2, "2026-03-14T02:00:00+00:00"),
]
mock_db = MagicMock()
mock_show = MagicMock()
mock_show.id = 1
mock_db.get_or_create_show.return_value = mock_show
poller = Poller(
db=mock_db,
soundcloud=mock_sc,
soundcloud_user="nicktherat",
show_day=2,
show_hour=22,
poll_interval=3600,
)
await poller.poll_once()
mock_sc.fetch_likes.return_value = [
_make_track(1, "2026-03-14T01:00:00+00:00"),
]
mock_db.reset_mock()
mock_db.get_or_create_show.return_value = mock_show
await poller.poll_once()
call_args = mock_db.set_show_tracks.call_args
assert call_args[0][1] == [1]
@pytest.mark.asyncio
async def test_poll_once_auto_assigns_episode_number():
mock_sc = AsyncMock()
mock_sc.resolve_user.return_value = 206979918
mock_sc.fetch_likes.return_value = [
_make_track(1, "2026-03-14T01:00:00+00:00"),
]
mock_db = MagicMock()
mock_show = MagicMock()
mock_show.id = 5
mock_show.episode_number = None
mock_db.get_or_create_show.return_value = mock_show
mock_db.get_latest_episode_number.return_value = 530
poller = Poller(
db=mock_db,
soundcloud=mock_sc,
soundcloud_user="nicktherat",
show_day=2,
show_hour=22,
poll_interval=3600,
)
await poller.poll_once()
mock_db.update_show_episode_number.assert_called_once_with(5, 531)
@pytest.mark.asyncio
async def test_poll_once_skips_numbering_when_no_history():
mock_sc = AsyncMock()
mock_sc.resolve_user.return_value = 206979918
mock_sc.fetch_likes.return_value = []
mock_db = MagicMock()
mock_show = MagicMock()
mock_show.id = 1
mock_show.episode_number = None
mock_db.get_or_create_show.return_value = mock_show
mock_db.get_latest_episode_number.return_value = None
poller = Poller(
db=mock_db,
soundcloud=mock_sc,
soundcloud_user="nicktherat",
show_day=2,
show_hour=22,
poll_interval=3600,
)
await poller.poll_once()
mock_db.update_show_episode_number.assert_not_called()
@pytest.mark.asyncio
async def test_poll_once_skips_numbering_when_already_assigned():
mock_sc = AsyncMock()
mock_sc.resolve_user.return_value = 206979918
mock_sc.fetch_likes.return_value = []
mock_db = MagicMock()
mock_show = MagicMock()
mock_show.id = 1
mock_show.episode_number = 530
mock_db.get_or_create_show.return_value = mock_show
poller = Poller(
db=mock_db,
soundcloud=mock_sc,
soundcloud_user="nicktherat",
show_day=2,
show_hour=22,
poll_interval=3600,
)
await poller.poll_once()
mock_db.get_latest_episode_number.assert_not_called()
mock_db.update_show_episode_number.assert_not_called()
@pytest.mark.asyncio
async def test_poll_once_full_refresh():
mock_sc = AsyncMock()
mock_sc.resolve_user.return_value = 206979918
mock_sc.fetch_likes.return_value = [
_make_track(1, "2026-03-14T01:00:00+00:00"),
]
mock_db = MagicMock()
mock_show = MagicMock()
mock_show.id = 1
mock_db.get_or_create_show.return_value = mock_show
poller = Poller(
db=mock_db,
soundcloud=mock_sc,
soundcloud_user="nicktherat",
show_day=2,
show_hour=22,
poll_interval=3600,
)
await poller.poll_once(full=True)
assert mock_sc.fetch_likes.called
@pytest.mark.asyncio
async def test_supervisor_restarts_poller_on_failure():
call_count = 0
async def failing_poll(**kwargs):
nonlocal call_count
call_count += 1
if call_count <= 2:
raise RuntimeError("Simulated failure")
mock_sc = AsyncMock()
mock_db = MagicMock()
poller = Poller(
db=mock_db,
soundcloud=mock_sc,
soundcloud_user="nicktherat",
show_day=2,
show_hour=22,
poll_interval=0.01,
)
poller.poll_once = failing_poll
task = asyncio.create_task(poller.run_supervised(restart_delay=0.01))
await asyncio.sleep(0.2)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert call_count >= 3