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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
185 lines
6.7 KiB
Python
185 lines
6.7 KiB
Python
"""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"]
|