Files
xiaoxia-saas/tests/unit/test_voice_clone_from_asset.py
T
xiaoxia 61295b9c25
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m43s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m33s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m19s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m39s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 41s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7m2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m27s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m1s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m45s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / Unit Tests (push) Successful in 13m47s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m58s
feat(tts): 正式合成支持克隆音色 + 保存到配音库改写 assets 素材体系 + 克隆接口支持 asset_id (#1556)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 18:46:06 +08:00

185 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]