217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""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
|