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
237 lines
5.9 KiB
Python
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
|