"""Integration test: Phase 2 end-to-end video upload, serve, transcribe, delete. Covers: - Full upload → transcribe flow (mocked ASR, real file I/O) - Transcribe with missing video returns 404 - Transcribe with ffmpeg failure returns 500 - Serve uploaded video returns correct content-type - Upload → serve → delete flow All external APIs (DashScope, ffmpeg) are mocked. Real FastAPI TestClient with real file I/O via tmp_path. """ import time from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from app.routers.video import router @pytest.fixture def video_client(tmp_path, monkeypatch): upload_dir = tmp_path / "test_uploads" upload_dir.mkdir() monkeypatch.setenv("VIDEO_UPLOAD_DIR", str(upload_dir)) monkeypatch.setenv("MAX_VIDEO_SIZE_MB", "50") monkeypatch.setenv("ASR_PROVIDER", "dashscope") monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test-key") from app.core.config import get_settings get_settings.cache_clear() app = FastAPI() app.include_router(router, prefix="/api/v1") return TestClient(app), upload_dir def _upload_video(client, filename="test.mp4", content=b"\x00" * 1024): """Helper: upload a video, return video_id.""" resp = client.post( "/api/v1/video/upload", files={"file": (filename, content, "video/mp4")}, ) assert resp.status_code == 200 return resp.json()["video_id"] class TestUploadTranscribeFlow: """Full upload → transcribe with mocked ASR and real file I/O.""" @patch("app.services.asr_providers.OpenAI") @patch("app.services.video_service.asyncio.create_subprocess_exec") def test_upload_then_transcribe(self, mock_subprocess, mock_openai_cls, video_client): """Upload video → extract audio (mocked ffmpeg) → transcribe (mocked ASR) → verify response.""" client, upload_dir = video_client # 1. Upload video content = b"\x00" * 2048 video_id = _upload_video(client, content=content) # 2. Mock ffmpeg subprocess to produce a fake WAV file async def fake_ffmpeg(*args, **kwargs): # Write a fake WAV so the transcribe endpoint can read it output_path = upload_dir / f"{video_id}_audio.wav" output_path.write_bytes(b"RIFF" + b"\x00" * 100) proc = AsyncMock() proc.returncode = 0 proc.communicate = AsyncMock(return_value=(b"ffmpeg output", b"")) return proc mock_subprocess.side_effect = fake_ffmpeg # 3. Mock ASR client (OpenAI-compatible DashScope call) mock_resp = MagicMock() mock_resp.choices = [MagicMock()] mock_resp.choices[0].message.content = "呢個係測試轉錄結果" mock_openai_instance = MagicMock() mock_openai_instance.chat.completions.create.return_value = mock_resp mock_openai_cls.return_value = mock_openai_instance # 4. Call transcribe resp = client.post(f"/api/v1/video/{video_id}/transcribe") assert resp.status_code == 200 data = resp.json() assert "text" in data assert data["language"] == "yue" assert len(data["text"]) > 0 # 5. Verify temp WAV was cleaned up wav_path = upload_dir / f"{video_id}_audio.wav" assert not wav_path.exists(), "Temp WAV file should be cleaned up after transcription" @patch("app.services.asr_providers.OpenAI") @patch("app.services.video_service.asyncio.create_subprocess_exec") def test_upload_transcribe_custom_language(self, mock_subprocess, mock_openai_cls, video_client): """Transcribe with language=en should pass it through.""" client, upload_dir = video_client video_id = _upload_video(client) async def fake_ffmpeg(*args, **kwargs): output_path = upload_dir / f"{video_id}_audio.wav" output_path.write_bytes(b"RIFF" + b"\x00" * 50) proc = AsyncMock() proc.returncode = 0 proc.communicate = AsyncMock(return_value=(b"", b"")) return proc mock_subprocess.side_effect = fake_ffmpeg mock_resp = MagicMock() mock_resp.choices = [MagicMock()] mock_resp.choices[0].message.content = "Hello world transcript" mock_openai_instance = MagicMock() mock_openai_instance.chat.completions.create.return_value = mock_resp mock_openai_cls.return_value = mock_openai_instance resp = client.post(f"/api/v1/video/{video_id}/transcribe?language=en") assert resp.status_code == 200 assert resp.json()["language"] == "en" assert "Hello" in resp.json()["text"] class TestTranscribeMissingVideo: """Transcribe on nonexistent video_id → 404.""" def test_transcribe_404_for_unknown_video(self, video_client): client, _ = video_client resp = client.post("/api/v1/video/nonexistent-video-id/transcribe") assert resp.status_code == 404 class TestTranscribeFFmpegFailure: """Transcribe with ffmpeg failure → 500.""" @patch("app.services.video_service.asyncio.create_subprocess_exec") def test_transcribe_ffmpeg_failure_returns_500(self, mock_subprocess, video_client): """If ffmpeg exits non-zero, transcribe should return 500.""" client, upload_dir = video_client video_id = _upload_video(client) async def failing_ffmpeg(*args, **kwargs): proc = AsyncMock() proc.returncode = 1 proc.communicate = AsyncMock(return_value=(b"", b"Error: Invalid data found")) return proc mock_subprocess.side_effect = failing_ffmpeg resp = client.post(f"/api/v1/video/{video_id}/transcribe") assert resp.status_code == 500 assert "Audio extraction failed" in resp.json()["detail"] class TestServeVideoContentType: """Serve uploaded video returns correct content-type.""" def test_serve_mp4_content_type(self, video_client): client, _ = video_client content = b"\x00" * 512 video_id = _upload_video(client, content=content) resp = client.get(f"/api/v1/video/{video_id}") assert resp.status_code == 200 assert resp.headers["content-type"] == "video/mp4" assert resp.content == content def test_serve_webm_content_type(self, video_client): client, _ = video_client content = b"\x00" * 256 resp = client.post( "/api/v1/video/upload", files={"file": ("test.webm", content, "video/webm")}, ) assert resp.status_code == 200 video_id = resp.json()["video_id"] resp = client.get(f"/api/v1/video/{video_id}") assert resp.status_code == 200 assert resp.headers["content-type"] == "video/webm" assert resp.content == content class TestUploadServeDeleteFlow: """Full lifecycle: upload → serve → delete → 404.""" def test_upload_serve_delete_lifecycle(self, video_client): client, upload_dir = video_client content = b"\x00" * 1024 # 1. Upload video_id = _upload_video(client, content=content) # 2. Serve — verify exists and content matches resp = client.get(f"/api/v1/video/{video_id}") assert resp.status_code == 200 assert resp.content == content # 3. Delete via VideoService directly from app.core.config import get_settings get_settings.cache_clear() from app.services.video_service import VideoService service = VideoService( upload_dir=str(upload_dir), max_size_mb=50, supported_formats=[".mp4", ".webm", ".mov", ".avi", ".mkv"], ) service.delete_video(video_id) # 4. Verify 404 after deletion resp = client.get(f"/api/v1/video/{video_id}") assert resp.status_code == 404