"""POST /voice-clones 支持 asset_id 的单元测试(路由级)。 覆盖:asset_id 成功(签名 URL 传给 workflow)/ 与 source_audio_url 同传 400 / 素材不存在 404 / 他人素材 403 / 非音频 400 / 无 storage_key 400 / 原 source_audio_url 方式不受影响。 """ from __future__ import annotations from unittest.mock import MagicMock import pytest class FakeAsset: def __init__(self, aid, project_id="proj-1", file_type="audio", storage_key="uploads/voice/a.mp3"): self.id = aid self.project_id = project_id self.file_type = file_type self.storage_key = storage_key class FakeAssetRepo: def __init__(self, assets): self._assets = {a.id: a for a in assets} def find_by_id(self, aid): return self._assets.get(aid) class FakeProject: def __init__(self, pid="proj-1", owner="user-1"): self.id = pid self.owner_user_id = owner def can_access(self, user_id): return user_id == self.owner_user_id class FakeProjectRepo: def __init__(self, projects): self._projects = {p.id: p for p in projects} def find_by_id(self, pid): return self._projects.get(pid) class FakeStorage: def get_download_url(self, key, expires_seconds=3600): return f"https://oss.example.com/signed/{key}?expires={expires_seconds}" def _build_app(*, assets, projects, current_user="user-1"): from fastapi import FastAPI app = FastAPI() from app.api.routes.voice_clones import _get_workflow_service, router from app.auth import get_current_user from app.core.storage import get_storage_service from app.dependencies import ( get_asset_repository, get_cosyvoice_service, get_project_repository, get_voice_clone_profile_repository, ) app.include_router(router, prefix="/voice-clones") fake_user = MagicMock() fake_user.user.id = current_user app.dependency_overrides[get_current_user] = lambda: fake_user asset_repo = FakeAssetRepo(assets) project_repo = FakeProjectRepo(projects) storage = FakeStorage() app.dependency_overrides[get_asset_repository] = lambda: asset_repo app.dependency_overrides[get_project_repository] = lambda: project_repo app.dependency_overrides[get_storage_service] = lambda: storage app.dependency_overrides[get_voice_clone_profile_repository] = lambda: MagicMock() app.dependency_overrides[get_cosyvoice_service] = lambda: MagicMock() from packages.domain.voice_clone_profile import VoiceCloneProfile workflow = MagicMock() profile = VoiceCloneProfile.create( user_id=current_user, name="我的克隆", voice_model="cosyvoice-v2", ) # pending 状态,不触发 celery send_task workflow.start_clone.return_value = profile app.dependency_overrides[_get_workflow_service] = lambda: workflow return app, workflow class TestCreateVoiceCloneFromAsset: def test_success_with_asset_id(self): from fastapi.testclient import TestClient asset = FakeAsset("asset-1") app, workflow = _build_app(assets=[asset], projects=[FakeProject()]) client = TestClient(app) resp = client.post("/voice-clones", json={"name": "我的克隆", "asset_id": "asset-1"}) assert resp.status_code in (200, 201), resp.text kwargs = workflow.start_clone.call_args.kwargs # worker 拿到的是服务端用 storage_key 生成的签名 URL,不依赖前端 assert kwargs["source_audio_url"].startswith("https://oss.example.com/signed/uploads/voice/a.mp3") assert "expires=604800" in kwargs["source_audio_url"] assert kwargs["metadata"]["source_asset_id"] == "asset-1" def test_asset_id_and_url_together_returns_400(self): from fastapi.testclient import TestClient app, workflow = _build_app(assets=[FakeAsset("asset-1")], projects=[FakeProject()]) client = TestClient(app) resp = client.post( "/voice-clones", json={ "name": "冲突", "asset_id": "asset-1", "source_audio_url": "https://example.com/a.mp3", }, ) assert resp.status_code == 400 workflow.start_clone.assert_not_called() def test_nonexistent_asset_returns_404(self): from fastapi.testclient import TestClient app, workflow = _build_app(assets=[], projects=[FakeProject()]) client = TestClient(app) resp = client.post("/voice-clones", json={"name": "x", "asset_id": "no-such-asset"}) assert resp.status_code == 404 workflow.start_clone.assert_not_called() def test_other_users_asset_returns_403(self): from fastapi.testclient import TestClient asset = FakeAsset("asset-2", project_id="proj-other") app, workflow = _build_app( assets=[asset], projects=[FakeProject(pid="proj-other", owner="other-user")], ) client = TestClient(app) resp = client.post("/voice-clones", json={"name": "越权", "asset_id": "asset-2"}) assert resp.status_code == 403 workflow.start_clone.assert_not_called() def test_non_audio_asset_returns_400(self): from fastapi.testclient import TestClient asset = FakeAsset("asset-3", file_type="video") app, workflow = _build_app(assets=[asset], projects=[FakeProject()]) client = TestClient(app) resp = client.post("/voice-clones", json={"name": "视频素材", "asset_id": "asset-3"}) assert resp.status_code == 400 assert "音频" in resp.json()["detail"] workflow.start_clone.assert_not_called() def test_asset_without_storage_key_returns_400(self): from fastapi.testclient import TestClient asset = FakeAsset("asset-4", storage_key="") app, workflow = _build_app(assets=[asset], projects=[FakeProject()]) client = TestClient(app) resp = client.post("/voice-clones", json={"name": "空key", "asset_id": "asset-4"}) assert resp.status_code == 400 workflow.start_clone.assert_not_called() def test_legacy_source_audio_url_still_works(self): """不传 asset_id、只传 source_audio_url 的旧流程保持兼容。""" from fastapi.testclient import TestClient app, workflow = _build_app(assets=[], projects=[]) client = TestClient(app) resp = client.post( "/voice-clones", json={"name": "旧流程", "source_audio_url": "https://example.com/voice.mp3"}, ) assert resp.status_code in (200, 201), resp.text kwargs = workflow.start_clone.call_args.kwargs assert kwargs["source_audio_url"] == "https://example.com/voice.mp3" assert "source_asset_id" not in kwargs["metadata"]