"""Integration test: Phase 3 end-to-end YouTube extract → HLS proxy. Covers: - Full extract → proxy flow with mocked yt-dlp and upstream HTTP - Proxy manifest rewriting through real HLSProxyService - Proxy segment passthrough through real HLSProxyService - Error handling: upstream failure after extract - Config: youtube_proxy_enabled flag All yt-dlp calls and upstream HTTP servers are mocked. Real FastAPI TestClient with real YouTube router and HLSProxyService. """ from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import quote import pytest from fastapi import FastAPI from fastapi.testclient import TestClient # --------------------------------------------------------------------------- # Helpers — fake yt-dlp format data # --------------------------------------------------------------------------- def _make_format( format_id: str, height: int | None = None, vcodec: str = "none", acodec: str = "none", ext: str = "mp4", protocol: str = "https", url: str = "", abr: float | None = None, tbr: float | None = None, resolution: str | None = None, ) -> dict: return { "format_id": format_id, "height": height, "width": height * 16 // 9 if height else None, "vcodec": vcodec, "acodec": acodec, "ext": ext, "protocol": protocol, "url": url or f"https://example.com/{format_id}.{ext}", "abr": abr, "tbr": tbr, "resolution": resolution or (f"{height * 16 // 9}x{height}" if height else None), } def _vod_info(video_id: str = "abc123") -> dict: return { "id": video_id, "title": "Test VOD Video", "thumbnail": f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg", "live_status": "not_live", "duration": 300, "formats": [ _make_format("137", height=1080, vcodec="avc1.640028", acodec="none", tbr=5000), _make_format("136", height=720, vcodec="avc1.640028", acodec="none", tbr=2500), _make_format("135", height=480, vcodec="avc1.640028", acodec="none", tbr=1200), _make_format("134", height=360, vcodec="avc1.640028", acodec="none", tbr=600), _make_format("133", height=240, vcodec="avc1.640028", acodec="none", tbr=300), _make_format("140", acodec="mp4a.40.2", vcodec="none", abr=128), _make_format("251", acodec="opus", vcodec="none", abr=160), _make_format("18", height=360, vcodec="avc1.42001E", acodec="mp4a.40.2", tbr=500), ], } def _vod_info_hls(video_id: str = "abc123") -> dict: return { "id": video_id, "title": "Test VOD with HLS", "thumbnail": f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg", "live_status": "not_live", "duration": 600, "formats": [ _make_format("136", height=720, vcodec="avc1.640028", acodec="none", ext="m3u8", protocol="m3u8_native", tbr=2500), _make_format("135", height=480, vcodec="avc1.640028", acodec="none", ext="m3u8", protocol="m3u8_native", tbr=1200), _make_format("140", acodec="mp4a.40.2", vcodec="none", ext="m3u8", protocol="m3u8_native", abr=128), ], } def _live_info(video_id: str = "live999") -> dict: return { "id": video_id, "title": "Live Stream Test", "thumbnail": f"https://i.ytimg.com/vi/{video_id}/hqdefault_live.jpg", "live_status": "is_live", "duration": None, "formats": [ _make_format("91", height=144, vcodec="avc1.42C00B", acodec="mp4a.40.5", ext="mp4", protocol="m3u8_native"), _make_format("92", height=240, vcodec="avc1.4D4015", acodec="mp4a.40.5", ext="mp4", protocol="m3u8_native"), _make_format("93", height=360, vcodec="avc1.4D401E", acodec="mp4a.40.2", ext="mp4", protocol="m3u8_native"), _make_format("94", height=480, vcodec="avc1.4D401F", acodec="mp4a.40.2", ext="mp4", protocol="m3u8_native", tbr=1200), _make_format("95", height=720, vcodec="avc1.4D401F", acodec="mp4a.40.2", ext="mp4", protocol="m3u8_native"), ], } def _make_mock_ydl(return_value: dict | Exception) -> MagicMock: mock_instance = MagicMock() if isinstance(return_value, Exception): mock_instance.extract_info.side_effect = return_value else: mock_instance.extract_info.return_value = return_value mock_ydl = MagicMock() mock_ydl.__enter__.return_value = mock_instance mock_ydl.__exit__.return_value = None return mock_ydl # --------------------------------------------------------------------------- # Helpers — mock httpx responses # --------------------------------------------------------------------------- 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 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 # --------------------------------------------------------------------------- # Shared fixture builder # --------------------------------------------------------------------------- def _build_client(monkeypatch, enabled: bool = True): from app.routers.youtube import router, _get_youtube_service from app.core.config import get_settings _get_youtube_service.cache_clear() get_settings.cache_clear() monkeypatch.setenv("YOUTUBE_PROXY_ENABLED", "true" if enabled else "false") monkeypatch.setenv("YT_DLP_TIMEOUT", "30") get_settings.cache_clear() app = FastAPI() app.include_router(router, prefix="/api/v1") return TestClient(app) # --------------------------------------------------------------------------- # Integration: Full extract → proxy flow # --------------------------------------------------------------------------- class TestExtractAndProxyFlow: """Full extract → proxy flow with mocked yt-dlp and upstream.""" @pytest.fixture def client(self, monkeypatch): return _build_client(monkeypatch, enabled=True) def test_extract_vod_then_proxy_manifest(self, client): mock_ydl = _make_mock_ydl(_vod_info_hls("vod1")) with patch("app.services.youtube_service.yt_dlp.YoutubeDL", return_value=mock_ydl): extract_resp = client.post( "/api/v1/youtube/extract", json={"url": "https://www.youtube.com/watch?v=vod1"}, ) assert extract_resp.status_code == 200 data = extract_resp.json() proxy_url = data["video_proxy_url"] assert proxy_url is not None assert "manifest.m3u8?url=" in proxy_url upstream_manifest = ( "#EXTM3U\n" "#EXT-X-VERSION:3\n" "#EXTINF:6.0,\n" "segment_0.ts\n" "#EXT-X-ENDLIST\n" ) upstream = _make_mock_stream_response( status_code=200, aiter_lines=lambda: _async_iter_lines(upstream_manifest), ) with patch("app.routers.youtube.httpx.AsyncClient") as mock_cls: mock_client = _make_mock_client(upstream) mock_cls.return_value = mock_client proxy_resp = client.get(proxy_url) assert proxy_resp.status_code == 200 assert proxy_resp.headers.get("access-control-allow-origin") == "*" content = proxy_resp.text assert "#EXTM3U" in content assert "/api/v1/youtube/proxy/segment.ts?url=" in content assert "#EXT-X-ENDLIST" in content def test_extract_vod_then_proxy_segment(self, client): mock_ydl = _make_mock_ydl(_vod_info_hls("vod2")) with patch("app.services.youtube_service.yt_dlp.YoutubeDL", return_value=mock_ydl): extract_resp = client.post( "/api/v1/youtube/extract", json={"url": "https://www.youtube.com/watch?v=vod2"}, ) assert extract_resp.status_code == 200 segment_upstream_url = "https://example.com/segment_0.ts" segment_proxy_url = ( f"/api/v1/youtube/proxy/segment.ts?url={quote(segment_upstream_url, safe='')}" ) resp_mock = _make_mock_stream_response( status_code=200, headers={"content-type": "video/mp2t"}, aiter_bytes=lambda: _async_iter_bytes([b"\x47" * 188]), ) client_mock = _make_mock_client(resp_mock) with patch("app.services.hls_proxy.httpx.AsyncClient", return_value=client_mock): seg_resp = client.get(segment_proxy_url) assert seg_resp.status_code == 200 assert seg_resp.headers.get("access-control-allow-origin") == "*" assert seg_resp.headers.get("content-type") == "video/mp2t" def test_extract_live_then_proxy_manifest(self, client): mock_ydl = _make_mock_ydl(_live_info("live1")) with patch("app.services.youtube_service.yt_dlp.YoutubeDL", return_value=mock_ydl): extract_resp = client.post( "/api/v1/youtube/extract", json={"url": "https://www.youtube.com/watch?v=live1"}, ) assert extract_resp.status_code == 200 data = extract_resp.json() assert data["is_live"] is True proxy_url = data["video_proxy_url"] assert proxy_url is not None live_manifest = ( "#EXTM3U\n" "#EXT-X-VERSION:3\n" "#EXT-X-TARGETDURATION:4\n" "#EXTINF:4.0,\n" "segment_live_0.ts\n" "#EXTINF:4.0,\n" "segment_live_1.ts\n" ) upstream = _make_mock_stream_response( status_code=200, aiter_lines=lambda: _async_iter_lines(live_manifest), ) with patch("app.routers.youtube.httpx.AsyncClient") as mock_cls: mock_client = _make_mock_client(upstream) mock_cls.return_value = mock_client proxy_resp = client.get(proxy_url) assert proxy_resp.status_code == 200 content = proxy_resp.text assert "#EXTM3U" in content assert "#EXT-X-ENDLIST" not in content assert "/api/v1/youtube/proxy/segment.ts?url=" in content def test_extract_cache_hit_bypasses_ytdlp(self, client): mock_ydl = _make_mock_ydl(_vod_info("cached_vod")) instance = mock_ydl.__enter__.return_value with patch("app.services.youtube_service.yt_dlp.YoutubeDL", return_value=mock_ydl): r1 = client.post( "/api/v1/youtube/extract", json={"url": "https://www.youtube.com/watch?v=cached_vod"}, ) r2 = client.post( "/api/v1/youtube/extract", json={"url": "https://www.youtube.com/watch?v=cached_vod"}, ) assert r1.status_code == 200 assert r2.status_code == 200 assert r1.json()["video_id"] == r2.json()["video_id"] assert instance.extract_info.call_count == 1 # --------------------------------------------------------------------------- # Integration: Upstream failures after successful extraction # --------------------------------------------------------------------------- class TestProxyAfterExtract: """Error scenarios where upstream fails after successful extraction.""" @pytest.fixture def client(self, monkeypatch): return _build_client(monkeypatch, enabled=True) def test_upstream_manifest_unavailable_after_extract(self, client): mock_ydl = _make_mock_ydl(_vod_info_hls("err1")) with patch("app.services.youtube_service.yt_dlp.YoutubeDL", return_value=mock_ydl): extract_resp = client.post( "/api/v1/youtube/extract", json={"url": "https://www.youtube.com/watch?v=err1"}, ) assert extract_resp.status_code == 200 proxy_url = extract_resp.json()["video_proxy_url"] upstream = _make_mock_stream_response(status_code=404) with patch("app.routers.youtube.httpx.AsyncClient") as mock_cls: mock_client = _make_mock_client(upstream) mock_cls.return_value = mock_client proxy_resp = client.get(proxy_url) assert proxy_resp.status_code == 502 # --------------------------------------------------------------------------- # Config: youtube_proxy_enabled flag # --------------------------------------------------------------------------- class TestExtractDisabled: """Config flag youtube_proxy_enabled=False.""" def test_extract_returns_503_when_disabled(self, monkeypatch): client = _build_client(monkeypatch, enabled=False) resp = client.post( "/api/v1/youtube/extract", json={"url": "https://www.youtube.com/watch?v=abc123"}, ) assert resp.status_code == 503