Files
xiaoxia-saas/tests/unit/test_voice_clone_from_asset.py
T
xiaoxia 61295b9c25
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 / Check push changed paths (push) Successful in 4m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m43s
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
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
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 / Staging E2E Tests (push) Successful in 5m58s
CI/CD Pipeline / Production Browser E2E (push) Failing after 404h25m27s
CI/CD Pipeline / Deploy Production (push) Failing after 404h25m28s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 404h25m29s
CI/CD Pipeline / Build Production API Image (push) Failing after 404h25m30s
CI/CD Pipeline / CI Gate (push) Failing after 404h25m30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 404h32m7s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 404h32m11s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 404h32m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 404h32m19s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 404h32m15s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 404h32m19s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 404h32m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 404h32m20s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 404h33m16s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 404h38m52s
CI/CD Pipeline / Build Staging Web Image (push) Failing after 404h34m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 404h38m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 404h38m58s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 404h39m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 404h39m4s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Failing after 404h39m8s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 404h39m10s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 404h39m14s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 404h39m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 404h39m18s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 404h39m20s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 404h39m22s
CI/CD Pipeline / PR Build Web Image (push) Failing after 404h39m20s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 404h39m22s
CI/CD Pipeline / PR Build API Image (push) Failing after 404h39m20s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 404h39m24s
CI/CD Pipeline / Canary Release to Production (push) Failing after 404h59m36s
CI/CD Pipeline / Build Production Web Image (push) Failing after 404h59m37s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 405h6m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 405h6m27s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 405h6m27s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 405h7m24s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 405h13m2s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 405h13m8s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 405h13m14s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 405h13m20s
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"]