""" 摄入任务 API 集成测试。 覆盖端点: - POST /ingest-jobs — 提交摄入任务 - GET /ingest-jobs/{job_id} — 获取摄入任务详情 使用 FastAPI TestClient + dependency_overrides 模式, 导入真实路由模块,mock Celery 和 repository。 """ from __future__ import annotations import os import sys from unittest.mock import MagicMock # ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ────────────────────────── os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") import pytest from fastapi import FastAPI from fastapi.testclient import TestClient sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) # mock celery_app 以避免实际发送任务 import app.api.routes.ingest_jobs as ingest_routes from app.api.routes.ingest_jobs import router from app.dependencies import get_ingest_job_repository from packages.adapters.in_memory import InMemoryIngestJobRepository from packages.domain import IngestJob, IngestJobStatus ingest_routes.celery_app = MagicMock() # --------------------------------------------------------------------------- # 1. 辅助函数 # --------------------------------------------------------------------------- def _make_job( project_id: str = "proj-1", library_id: str = "lib-1", storage_key: str = "uploads/test.mp4", status: IngestJobStatus = IngestJobStatus.PENDING, ) -> IngestJob: job = IngestJob.create( project_id=project_id, library_id=library_id, storage_key=storage_key, ) if status == IngestJobStatus.PROCESSING: job.status = IngestJobStatus.PROCESSING elif status == IngestJobStatus.COMPLETED: job.status = IngestJobStatus.COMPLETED job.result_asset_id = "asset-completed-001" elif status == IngestJobStatus.FAILED: job.status = IngestJobStatus.FAILED job.error_message = "文件解析失败" return job # --------------------------------------------------------------------------- # 2. Fixtures # --------------------------------------------------------------------------- @pytest.fixture def repo(): return InMemoryIngestJobRepository() @pytest.fixture def client(repo): """创建带有依赖覆盖的 TestClient。""" test_app = FastAPI() test_app.include_router(router, prefix="/ingest-jobs") def _override_repo(): return repo test_app.dependency_overrides[get_ingest_job_repository] = _override_repo yield TestClient(test_app) test_app.dependency_overrides.clear() # --------------------------------------------------------------------------- # 3. POST / — 提交摄入任务 # --------------------------------------------------------------------------- class TestSubmitIngestJob: """提交摄入任务端点测试。""" def test_submit_with_valid_data(self, client): """使用有效数据提交摄入任务应成功。""" resp = client.post( "/ingest-jobs", json={ "project_id": "proj-123", "library_id": "lib-456", "storage_key": "uploads/video.mp4", }, ) assert resp.status_code == 200 data = resp.json() assert data["project_id"] == "proj-123" assert data["library_id"] == "lib-456" assert data["storage_key"] == "uploads/video.mp4" assert data["status"] == "pending" assert data["error_message"] == "" assert data["result_asset_id"] == "" or data["result_asset_id"] is None assert "id" in data assert len(data["id"]) > 0 def test_submit_generates_unique_id(self, client): """每次提交应生成不同的任务 ID。""" resp1 = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "l1", "storage_key": "a.mp4"}, ) resp2 = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "l1", "storage_key": "b.mp4"}, ) assert resp1.json()["id"] != resp2.json()["id"] def test_submit_missing_project_id_returns_422(self, client): """缺少 project_id 应返回 422。""" resp = client.post( "/ingest-jobs", json={"library_id": "lib-1", "storage_key": "uploads/test.mp4"}, ) assert resp.status_code == 422 def test_submit_missing_library_id_returns_422(self, client): """缺少 library_id 应返回 422。""" resp = client.post( "/ingest-jobs", json={"project_id": "proj-1", "storage_key": "uploads/test.mp4"}, ) assert resp.status_code == 422 def test_submit_missing_storage_key_returns_422(self, client): """缺少 storage_key 应返回 422。""" resp = client.post( "/ingest-jobs", json={"project_id": "proj-1", "library_id": "lib-1"}, ) assert resp.status_code == 422 def test_submit_empty_project_id_returns_422(self, client): """空 project_id 应返回 422。""" resp = client.post( "/ingest-jobs", json={"project_id": "", "library_id": "lib-1", "storage_key": "x.mp4"}, ) assert resp.status_code == 422 def test_submit_empty_library_id_returns_422(self, client): """空 library_id 应返回 422。""" resp = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "", "storage_key": "x.mp4"}, ) assert resp.status_code == 422 def test_submit_empty_storage_key_returns_422(self, client): """空 storage_key 应返回 422。""" resp = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "l1", "storage_key": ""}, ) assert resp.status_code == 422 def test_submit_sends_celery_task(self, client): """提交任务后应触发 Celery 异步任务。""" ingest_routes.celery_app.send_task.reset_mock() resp = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "l1", "storage_key": "x.mp4"}, ) assert resp.status_code == 200 job_id = resp.json()["id"] ingest_routes.celery_app.send_task.assert_called_once_with( "worker.ingest_asset", args=[job_id], ) def test_submit_persists_to_repository(self, client, repo): """提交后任务应保存到 repository。""" resp = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "l1", "storage_key": "test.mp4"}, ) job_id = resp.json()["id"] saved = repo.get(job_id) assert saved is not None assert saved.project_id == "p1" assert saved.library_id == "l1" assert saved.storage_key == "test.mp4" assert saved.status == IngestJobStatus.PENDING def test_submit_with_different_file_types(self, client): """支持不同文件类型的 storage_key。""" for key in ["uploads/image.jpg", "videos/clip.mov", "audio/sound.mp3"]: resp = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "l1", "storage_key": key}, ) assert resp.status_code == 200 assert resp.json()["storage_key"] == key # --------------------------------------------------------------------------- # 4. GET /{job_id} — 获取摄入任务详情 # --------------------------------------------------------------------------- class TestGetIngestJob: """获取摄入任务详情端点测试。""" def test_get_pending_job(self, client, repo): """获取 pending 状态的任务。""" job = _make_job(status=IngestJobStatus.PENDING) repo.create(job) resp = client.get(f"/ingest-jobs/{job.id}") assert resp.status_code == 200 data = resp.json() assert data["id"] == job.id assert data["status"] == "pending" assert data["result_asset_id"] == "" or data["result_asset_id"] is None def test_get_processing_job(self, client, repo): """获取 processing 状态的任务。""" job = _make_job(status=IngestJobStatus.PROCESSING) repo.create(job) resp = client.get(f"/ingest-jobs/{job.id}") assert resp.status_code == 200 assert resp.json()["status"] == "processing" def test_get_completed_job(self, client, repo): """获取已完成的任务应包含 result_asset_id。""" job = _make_job(status=IngestJobStatus.COMPLETED) repo.create(job) resp = client.get(f"/ingest-jobs/{job.id}") assert resp.status_code == 200 data = resp.json() assert data["status"] == "completed" assert data["result_asset_id"] == "asset-completed-001" assert data["error_message"] == "" def test_get_failed_job(self, client, repo): """获取失败的任务应包含错误信息。""" job = _make_job(status=IngestJobStatus.FAILED) repo.create(job) resp = client.get(f"/ingest-jobs/{job.id}") assert resp.status_code == 200 data = resp.json() assert data["status"] == "failed" assert "文件解析失败" in data["error_message"] def test_get_nonexistent_job_raises_error(self, client): """获取不存在的任务会抛出 ValueError(当前实现未使用 HTTPException)。""" # 注:路由中使用 raise ValueError 而非 HTTPException, # 在 TestClient 中会以异常形式抛出。生产环境会返回 500。 # 此处验证当前行为:当 job 不存在时会报错。 try: resp = client.get("/ingest-jobs/nonexistent-job-id") # 如果 FastAPI 捕获了异常,会返回 500 assert resp.status_code == 500 except (ValueError, Exception): # TestClient 中 ValueError 可能直接抛出 pass # 符合预期:不存在的任务会报错 def test_response_contains_all_required_fields(self, client, repo): """响应应包含所有必需字段。""" job = _make_job() repo.create(job) resp = client.get(f"/ingest-jobs/{job.id}") data = resp.json() for field in ["id", "project_id", "library_id", "storage_key", "status", "error_message"]: assert field in data, f"缺少字段: {field}" # --------------------------------------------------------------------------- # 5. 跨端点场景 # --------------------------------------------------------------------------- class TestIngestApiScenarios: """摄入任务 API 跨端点集成场景。""" def test_submit_then_get_pending(self, client, repo): """提交任务后立即查询应为 pending 状态。""" submit_resp = client.post( "/ingest-jobs", json={ "project_id": "proj-scenario", "library_id": "lib-scenario", "storage_key": "uploads/scenario.mp4", }, ) assert submit_resp.status_code == 200 job_id = submit_resp.json()["id"] get_resp = client.get(f"/ingest-jobs/{job_id}") assert get_resp.status_code == 200 assert get_resp.json()["status"] == "pending" assert get_resp.json()["storage_key"] == "uploads/scenario.mp4" def test_submit_simulate_complete_then_get(self, client, repo): """模拟 worker 完成任务后查询应返回 asset_id。""" submit_resp = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "l1", "storage_key": "video.mp4"}, ) job_id = submit_resp.json()["id"] # 模拟 worker 处理完成 job = repo.get(job_id) assert job is not None job.status = IngestJobStatus.COMPLETED job.result_asset_id = "asset-new-001" repo.update(job) get_resp = client.get(f"/ingest-jobs/{job_id}") assert get_resp.status_code == 200 data = get_resp.json() assert data["status"] == "completed" assert data["result_asset_id"] == "asset-new-001" def test_submit_simulate_failure_then_get(self, client, repo): """模拟 worker 失败后查询应返回错误信息。""" submit_resp = client.post( "/ingest-jobs", json={"project_id": "p1", "library_id": "l1", "storage_key": "bad.mp4"}, ) job_id = submit_resp.json()["id"] # 模拟处理失败 job = repo.get(job_id) assert job is not None job.status = IngestJobStatus.FAILED job.error_message = "文件格式不支持" repo.update(job) get_resp = client.get(f"/ingest-jobs/{job_id}") assert get_resp.status_code == 200 data = get_resp.json() assert data["status"] == "failed" assert "文件格式不支持" in data["error_message"] if __name__ == "__main__": pytest.main([__file__, "-v"])