from datetime import UTC, datetime from unittest.mock import AsyncMock import pytest from sqlalchemy import create_engine, event from sqlalchemy.orm import Session from app.db.session import Base from app.models.collected_data import CollectedData from app.models.data_snapshot import DataSnapshot from app.models.system_setting import SystemSetting from app.models.task import CollectionTask from app.services.collectors.news_live_streams import NewsLiveStreamsCollector from app.services.tv_catalog import get_tv_catalog_page from app.services.tv_streams import normalize_tv_settings class CatalogSession: """Execute the real catalog queries against an isolated SQLite database.""" def __init__(self, session): self.session = session async def execute(self, query): return self.session.execute(query) async def scalar(self, query): return self.session.scalar(query) async def scalars(self, query): return self.session.scalars(query) @pytest.fixture def catalog_db(): engine = create_engine("sqlite:///:memory:") @event.listens_for(engine, "connect") def register_functions(connection, _record): connection.create_function( "concat_ws", -1, lambda sep, *args: sep.join(str(arg) for arg in args if arg is not None), ) Base.metadata.create_all( engine, tables=[ CollectionTask.__table__, DataSnapshot.__table__, CollectedData.__table__, SystemSetting.__table__, ], ) with Session(engine) as session: for index in range(135): session.add( CollectedData( source="news_live_streams", source_id=f"channel-{index:03}", data_type="news_live_stream", name=f"Channel {index:03}", collected_at=datetime(2026, 9, 13, tzinfo=UTC), is_current=True, is_valid=1, extra_data={ "stream_url": "https://example.invalid/live.m3u8", "region": "Canada", }, ) ) session.flush() yield CatalogSession(session) engine.dispose() @pytest.mark.asyncio async def test_pages_include_entire_catalog_without_overlap(catalog_db): first = await get_tv_catalog_page(catalog_db, limit=50) second = await get_tv_catalog_page(catalog_db, offset=first["next_offset"], limit=50) third = await get_tv_catalog_page(catalog_db, offset=second["next_offset"], limit=50) ids = [source["id"] for page in (first, second, third) for source in page["sources"]] assert [len(page["sources"]) for page in (first, second, third)] == [50, 50, 45] assert len(set(ids)) == first["source_count"] == 145 assert ids[-1] == "channel-134" assert third["next_offset"] is None and not third["has_more"] beyond = await get_tv_catalog_page(catalog_db, offset=200) assert beyond["sources"] == [] and not beyond["has_more"] @pytest.mark.asyncio async def test_search_finds_later_pages_and_treats_wildcards_literally(catalog_db): payload = await get_tv_catalog_page(catalog_db, q="CANADA 134") assert [source["id"] for source in payload["sources"]] == ["channel-134"] assert payload["total"] == 1 and payload["source_count"] == 145 assert (await get_tv_catalog_page(catalog_db, q="%"))["total"] == 0 @pytest.mark.asyncio async def test_selection_survives_refresh_when_outside_first_page(catalog_db): payload = await get_tv_catalog_page(catalog_db, selected_id="channel-134") assert payload["selected_source"]["id"] == "channel-134" assert "channel-134" not in [source["id"] for source in payload["sources"]] assert payload["default_source_id"] == "aljazeera-mubasher" default = (await get_tv_catalog_page(catalog_db, selected_id="removed"))["selected_source"] assert default["id"] == "aljazeera-mubasher" and default["source_type"] == "hls" @pytest.mark.asyncio async def test_catalog_hides_inactive_records_and_deduplicates_ids(catalog_db): for name, values in [ ("Disabled", {"extra_data": {"is_enabled": False}}), ("Historical", {"is_current": False}), ("Invalid", {"is_valid": 0}), ("Deleted", {"deleted_at": datetime.now(UTC)}), ("Replacement", {"source_id": "channel-134"}), ]: record = dict( source="news_live_streams", source_id=name, name=name, data_type="news_live_stream", is_current=True, is_valid=1, ) catalog_db.session.add(CollectedData(**{**record, **values})) catalog_db.session.flush() payload = await get_tv_catalog_page(catalog_db, q="replacement") assert payload["source_count"] == 145 assert [source["id"] for source in payload["sources"]] == ["channel-134"] def test_missing_builtin_default_adds_aljazeera_without_losing_custom_source(): settings = normalize_tv_settings({"sources": [{"id": "custom", "name": "Custom"}]}) assert settings["default_source_id"] == "aljazeera-mubasher" assert {source["id"] for source in settings["sources"]} == {"custom", "aljazeera-mubasher"} @pytest.mark.asyncio @pytest.mark.parametrize( "config, expected", [({}, 135), ({"max_sources": 0}, 135), ({"max_sources": 7}, 7)] ) async def test_collector_keeps_all_matching_channels_unless_explicitly_limited( monkeypatch, config, expected ): collector = NewsLiveStreamsCollector() channels = [ {"id": f"channel-{i}", "name": f"Channel {i}", "categories": ["news"]} for i in range(135) ] channels.append({"id": "sport", "name": "Sports", "categories": ["sports"]}) streams = [ {"channel": channel["id"], "url": "https://example.invalid/live.m3u8"} for channel in channels ] monkeypatch.setattr( collector, "_gather_iptv_org_payloads", AsyncMock(return_value=(channels, streams, [])) ) records = await collector._fetch_iptv_org("https://example.invalid/channels.json", config) assert len(records) == expected assert all(record["source_id"] != "sport" for record in records)