legco_ai_assistant/backend/app/test/test_integration_phase2.py

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