161 lines
6.1 KiB
Python
161 lines
6.1 KiB
Python
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)
|