legco_ai_assistant/backend/app/test/test_phase3_hls_proxy.py

338 lines
13 KiB
Python

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