Files
planet/backend/tests/test_tv_catalog.py
rayd1o 58671e7bc3
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.74.4
2026-09-13 10:27:00 +08:00

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)