"""Phase 3.3 tests: HLS proxy service — manifest rewriting and segment proxying. Covers: - Manifest line rewriting: segments, sub-manifests, EXT-X-KEY URIs, pass-through tags - URL resolution: relative paths, absolute paths, absolute URLs - Segment proxying: StreamingResponse with correct Content-Type and CORS headers - Route integration: GET /youtube/proxy/manifest.m3u8 and /segment.ts - Error handling: upstream failures → 502, client disconnect - CORS headers on every response """ from unittest.mock import AsyncMock, MagicMock, patch import pytest from httpx import Response, Request def _make_mock_stream_response(status_code: int = 200, **kwargs) -> MagicMock: mock = MagicMock() mock.status_code = status_code mock.aclose = AsyncMock() mock.__aenter__ = AsyncMock(return_value=mock) mock.__aexit__ = AsyncMock(return_value=None) for key, value in kwargs.items(): setattr(mock, key, value) return mock def _make_mock_client(resp_mock: MagicMock) -> MagicMock: client = MagicMock() client.stream = MagicMock(return_value=resp_mock) client.send = AsyncMock(return_value=resp_mock) client.build_request = MagicMock(return_value=MagicMock()) client.aclose = AsyncMock() client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=None) return client # --------------------------------------------------------------------------- # Unit: Line rewriting # --------------------------------------------------------------------------- class TestLineRewriting: @pytest.fixture def svc(self): from app.services.hls_proxy import HLSProxyService return HLSProxyService() def test_passes_through_comment_tags(self, svc): base = "https://example.com/manifest.m3u8" assert svc._rewrite_line("#EXTM3U", base) == "#EXTM3U" assert svc._rewrite_line("#EXT-X-VERSION:3", base) == "#EXT-X-VERSION:3" assert svc._rewrite_line("#EXT-X-TARGETDURATION:6", base) == "#EXT-X-TARGETDURATION:6" assert svc._rewrite_line("#EXT-X-MEDIA-SEQUENCE:0", base) == "#EXT-X-MEDIA-SEQUENCE:0" assert svc._rewrite_line("#EXT-X-ENDLIST", base) == "#EXT-X-ENDLIST" assert svc._rewrite_line("# This is a comment", base) == "# This is a comment" assert svc._rewrite_line("#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360", base) == "#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360" def test_passes_through_empty_lines(self, svc): assert svc._rewrite_line("", "https://example.com/base.m3u8") == "" def test_rewrites_ts_segment(self, svc): base = "https://example.com/path/manifest.m3u8" result = svc._rewrite_line("segment_0.ts", base) assert result.startswith("/api/v1/youtube/proxy/segment.ts?url=") def test_rewrites_m3u8_submanifest(self, svc): base = "https://example.com/path/manifest.m3u8" result = svc._rewrite_line("variant_360p.m3u8", base) assert result.startswith("/api/v1/youtube/proxy/manifest.m3u8?url=") def test_rewrites_ext_x_key_uri(self, svc): base = "https://example.com/manifest.m3u8" result = svc._rewrite_line('#EXT-X-KEY:METHOD=AES-128,URI="key.bin",IV=0x1234', base) assert result.startswith("#EXT-X-KEY:METHOD=AES-128,URI=\"") assert "/api/v1/youtube/proxy/segment.ts?url=" in result def test_rewrites_m3u8_key_uri(self, svc): base = "https://example.com/manifest.m3u8" result = svc._rewrite_line('#EXT-X-KEY:METHOD=AES-128,URI="keys/variant.m3u8"', base) assert result.startswith("#EXT-X-KEY:METHOD=AES-128,URI=\"") assert "/api/v1/youtube/proxy/manifest.m3u8?url=" in result def test_rewrites_absolute_url_segment(self, svc): base = "https://example.com/manifest.m3u8" result = svc._rewrite_line("https://cdn.example.com/segments/0.ts", base) assert result.startswith("/api/v1/youtube/proxy/segment.ts?url=") def test_passes_through_inf_tag_with_commas(self, svc): base = "https://example.com/manifest.m3u8" result = svc._rewrite_line("#EXTINF:6.000,Some description, with commas", base) assert result == "#EXTINF:6.000,Some description, with commas" # --------------------------------------------------------------------------- # Unit: URL resolution # --------------------------------------------------------------------------- class TestURLResolution: @pytest.fixture def svc(self): from app.services.hls_proxy import HLSProxyService return HLSProxyService() def test_relative_path_resolved(self, svc): result = svc._resolve_url("segment_0.ts", "https://example.com/path/manifest.m3u8") assert result == "https://example.com/path/segment_0.ts" def test_absolute_path_resolved(self, svc): result = svc._resolve_url("/segments/0.ts", "https://example.com/path/manifest.m3u8") assert result == "https://example.com/segments/0.ts" def test_absolute_url_passthrough(self, svc): result = svc._resolve_url("https://cdn.example.com/0.ts", "https://example.com/manifest.m3u8") assert result == "https://cdn.example.com/0.ts" def test_parent_dir_resolved(self, svc): result = svc._resolve_url("../segments/0.ts", "https://example.com/path/to/manifest.m3u8") assert result == "https://example.com/path/segments/0.ts" # --------------------------------------------------------------------------- # Unit: Proxy URL construction # --------------------------------------------------------------------------- class TestProxyURLConstruction: @pytest.fixture def svc(self): from app.services.hls_proxy import HLSProxyService return HLSProxyService() def test_segment_extension_uses_segment_proxy(self, svc): from urllib.parse import unquote upstream = "https://cdn.example.com/segments/0.ts" proxy = svc._build_proxy_url_for_uri(upstream) assert proxy.startswith("/api/v1/youtube/proxy/segment.ts?url=") encoded = proxy.split("url=", 1)[1] assert unquote(encoded) == upstream def test_m3u8_extension_uses_manifest_proxy(self, svc): from urllib.parse import unquote upstream = "https://cdn.example.com/variants/360p.m3u8" proxy = svc._build_proxy_url_for_uri(upstream) assert proxy.startswith("/api/v1/youtube/proxy/manifest.m3u8?url=") encoded = proxy.split("url=", 1)[1] assert unquote(encoded) == upstream def test_unknown_extension_uses_segment_proxy(self, svc): from urllib.parse import unquote upstream = "https://cdn.example.com/init.mp4" proxy = svc._build_proxy_url_for_uri(upstream) assert proxy.startswith("/api/v1/youtube/proxy/segment.ts?url=") # --------------------------------------------------------------------------- # Integration: Manifest rewriting with mocked httpx # --------------------------------------------------------------------------- SAMPLE_MANIFEST = """#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:6 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:6.000, segment_0.ts #EXTINF:6.000, segment_1.ts #EXT-X-ENDLIST """ class TestManifestRewriting: @pytest.fixture def svc(self): from app.services.hls_proxy import HLSProxyService return HLSProxyService() @pytest.mark.asyncio async def test_full_manifest_rewritten(self, svc): upstream = _make_mock_stream_response( status_code=200, aiter_lines=lambda: _async_iter_lines(SAMPLE_MANIFEST), ) lines = [] async for line in svc.rewrite_manifest("https://example.com/video.m3u8", upstream): lines.append(line) assert lines[0] == "#EXTM3U\n" assert lines[1] == "#EXT-X-VERSION:3\n" assert lines[2] == "#EXT-X-TARGETDURATION:6\n" assert lines[3] == "#EXT-X-MEDIA-SEQUENCE:0\n" assert lines[4] == "#EXTINF:6.000,\n" assert "/api/v1/youtube/proxy/segment.ts?url=" in lines[5] assert lines[6] == "#EXTINF:6.000,\n" assert "/api/v1/youtube/proxy/segment.ts?url=" in lines[7] assert lines[8] == "#EXT-X-ENDLIST\n" @pytest.mark.asyncio async def test_master_manifest_with_variants(self, svc): master = """#EXTM3U #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360 variant_360p.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=842x480 variant_480p.m3u8 """ upstream = _make_mock_stream_response( status_code=200, aiter_lines=lambda: _async_iter_lines(master), ) lines = [line async for line in svc.rewrite_manifest("https://example.com/master.m3u8", upstream)] assert "#EXT-X-STREAM-INF" in lines[1] assert "/api/v1/youtube/proxy/manifest.m3u8?url=" in lines[2] assert "/api/v1/youtube/proxy/manifest.m3u8?url=" in lines[4] # --------------------------------------------------------------------------- # Integration: Segment proxying with mocked httpx # --------------------------------------------------------------------------- class TestSegmentProxying: @pytest.fixture def svc(self): from app.services.hls_proxy import HLSProxyService return HLSProxyService() @pytest.mark.asyncio async def test_proxy_segment_returns_streaming_response(self, svc): resp_mock = _make_mock_stream_response( status_code=200, headers={"content-type": "video/mp2t"}, aiter_bytes=lambda: _async_iter_bytes([b"\x47"] * 100), ) client_mock = _make_mock_client(resp_mock) with patch("app.services.hls_proxy.httpx.AsyncClient", return_value=client_mock): from fastapi.responses import StreamingResponse result = await svc.proxy_segment("https://cdn.example.com/0.ts") assert isinstance(result, StreamingResponse) assert result.media_type == "video/mp2t" assert result.headers.get("access-control-allow-origin") == "*" # --------------------------------------------------------------------------- # Integration: Route tests # --------------------------------------------------------------------------- class TestProxyRoutes: @pytest.fixture def proxy_client(self): from app.routers.youtube import router from app.core.config import get_settings get_settings.cache_clear() from fastapi import FastAPI from fastapi.testclient import TestClient app = FastAPI() app.include_router(router, prefix="/api/v1") return TestClient(app) def test_manifest_proxy_returns_cors_header(self, proxy_client): upstream = _make_mock_stream_response( status_code=200, aiter_lines=lambda: _async_iter_lines("#EXTM3U\n#EXT-X-ENDLIST\n"), ) with patch("app.routers.youtube.httpx.AsyncClient") as mock_client_cls: mock_client = _make_mock_client(upstream) mock_client_cls.return_value = mock_client from urllib.parse import quote encoded_url = quote("https://example.com/video.m3u8", safe="") resp = proxy_client.get(f"/api/v1/youtube/proxy/manifest.m3u8?url={encoded_url}") assert resp.status_code == 200 assert resp.headers.get("access-control-allow-origin") == "*" def test_segment_proxy_returns_correct_content_type(self, proxy_client): resp_mock = _make_mock_stream_response( status_code=200, headers={"content-type": "video/mp2t"}, aiter_bytes=lambda: _async_iter_bytes([b"\x47"] * 50), ) client_mock = _make_mock_client(resp_mock) with patch("app.services.hls_proxy.httpx.AsyncClient", return_value=client_mock): from urllib.parse import quote encoded_url = quote("https://cdn.example.com/0.ts", safe="") resp = proxy_client.get(f"/api/v1/youtube/proxy/segment.ts?url={encoded_url}") assert resp.status_code == 200 assert resp.headers.get("access-control-allow-origin") == "*" assert resp.headers.get("content-type") == "video/mp2t" def test_proxy_missing_url_parameter_returns_422(self, proxy_client): resp = proxy_client.get("/api/v1/youtube/proxy/manifest.m3u8") assert resp.status_code == 422 def test_proxy_upstream_404_returns_502(self, proxy_client): upstream = _make_mock_stream_response(status_code=404) with patch("app.routers.youtube.httpx.AsyncClient") as mock_client_cls: mock_client = _make_mock_client(upstream) mock_client_cls.return_value = mock_client from urllib.parse import quote encoded_url = quote("https://cdn.example.com/missing.ts", safe="") resp = proxy_client.get(f"/api/v1/youtube/proxy/manifest.m3u8?url={encoded_url}") # Route checks upstream status before streaming → raises 502 assert resp.status_code == 502 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _async_iter_lines(text: str): for line in text.split("\n"): yield line async def _async_iter_bytes(chunks: list[bytes]): for chunk in chunks: yield chunk