legco_ai_assistant/backend/app/test/test_integration_phase3.py

362 lines
13 KiB
Python

"""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