362 lines
13 KiB
Python
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
|