✨ feat: WP-08 媒体基础与 CI(上传/文件/任务/声契 + 三任务流水线)
- 迁移 15:upload(三态)、file_asset(sha256/存储键/pending-ready-quarantined)、media_job(四类五态+进度+取消)、voice_consent(用途/期限/撤回) - 上传三步流(契约第 9 节开发档):POST uploads(MIME 白名单+大小约束)→ PUT data(真实写 storage/ 落盘、sha256 校验)→ completions 转 ready——未完成文件不可被业务引用 - 文件内容取回(开发档本地;正式部署换短期签名 URL,存储键不变) - 媒体任务:ASR 输入必须 ready 音频;后台执行经注入 Provider(测试 mock 确定性转写);无 Provider 显式失败(fail-closed);取消幂等 - 声契:TTS/克隆需有效声契(未撤回未过期);撤回后新任务 403(ADR 媒体域“撤回停止新生成”) - CI(WP-14 起步):三任务流水线——backend(pgvector 服务容器 + ruff/mypy/pytest)、engine(CMake 警告即错误 + ctest)、openapi(Redocly lint) - 测试 91 项全绿(+5)
This commit is contained in:
1 parent
9ef2568c3b
commit
701c20c783
7 files changed
+400
No files matched your search
@@ -1594,3 +1594,112 @@ class TestPlugins:
|
||||
).scalars().all()
|
||||
)
|
||||
assert len(audits) == 1 # 撤架前那次调用仍在审计链
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
class TestMedia:
|
||||
async def _upload_audio(self, client, headers) -> str:
|
||||
up = await client.post(
|
||||
"/v1/uploads",
|
||||
json={"filename": "memo.wav", "media_type": "audio/wav"}, headers=headers,
|
||||
)
|
||||
assert up.status_code == 201, up.text
|
||||
data = await client.request(
|
||||
"PUT", f"/v1/uploads/{up.json()['id']}/data",
|
||||
content=b"RIFF....fake-wav-bytes",
|
||||
headers={**headers, "Content-Type": "application/octet-stream"},
|
||||
)
|
||||
assert data.status_code == 200, data.text
|
||||
done = await client.post(
|
||||
f"/v1/uploads/{up.json()['id']}/completions", headers=headers
|
||||
)
|
||||
assert done.status_code == 201 and done.json()["status"] == "ready"
|
||||
return done.json()["id"]
|
||||
|
||||
async def test_upload_three_step_flow(self, client) -> None:
|
||||
headers = await login(client, "md1@example.com")
|
||||
file_id = await self._upload_audio(client, headers)
|
||||
meta = await client.get(f"/v1/files/{file_id}", headers=headers)
|
||||
assert meta.json()["size_bytes"] == len(b"RIFF....fake-wav-bytes")
|
||||
assert meta.json()["sha256"].startswith("sha256:")
|
||||
content = await client.get(f"/v1/files/{file_id}/content", headers=headers)
|
||||
assert content.status_code == 200 and content.content == b"RIFF....fake-wav-bytes"
|
||||
|
||||
async def test_upload_rejects_bad_mime(self, client) -> None:
|
||||
headers = await login(client, "md2@example.com")
|
||||
resp = await client.post(
|
||||
"/v1/uploads",
|
||||
json={"filename": "x.exe", "media_type": "application/x-executable"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_asr_job_with_mock_provider(self, client, db) -> None:
|
||||
import asyncio
|
||||
|
||||
from app.models import MediaJob as MJ
|
||||
|
||||
headers = await login(client, "md3@example.com")
|
||||
file_id = await self._upload_audio(client, headers)
|
||||
job = await client.post(
|
||||
"/v1/media/jobs",
|
||||
json={"kind": "asr", "input": {"file_id": file_id}}, headers=headers,
|
||||
)
|
||||
assert job.status_code == 201, job.text
|
||||
job_id = job.json()["id"]
|
||||
deadline = asyncio.get_event_loop().time() + 10
|
||||
final = None
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
async with db() as session:
|
||||
row = await session.get(MJ, job_id)
|
||||
if row and row.status in {"succeeded", "failed", "cancelled"}:
|
||||
final = row
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert final is not None and final.status == "succeeded", (
|
||||
final.error if final else "missing"
|
||||
)
|
||||
assert final.output["transcript"]
|
||||
|
||||
async def test_asr_requires_ready_audio(self, client) -> None:
|
||||
headers = await login(client, "md4@example.com")
|
||||
up = await client.post(
|
||||
"/v1/uploads",
|
||||
json={"filename": "t.txt", "media_type": "text/plain"}, headers=headers,
|
||||
)
|
||||
await client.request(
|
||||
"PUT", f"/v1/uploads/{up.json()['id']}/data",
|
||||
content=b"hello", headers=headers,
|
||||
)
|
||||
job = await client.post(
|
||||
"/v1/media/jobs",
|
||||
json={"kind": "asr", "input": {"file_id": "file_nonexistent"}}, headers=headers,
|
||||
)
|
||||
assert job.status_code == 404 # 未就绪/不存在文件不可被引用
|
||||
|
||||
async def test_tts_requires_voice_consent(self, client) -> None:
|
||||
headers = await login(client, "md5@example.com")
|
||||
denied = await client.post(
|
||||
"/v1/media/jobs",
|
||||
json={"kind": "tts", "input": {"text": "你好"}}, headers=headers,
|
||||
)
|
||||
assert denied.status_code == 403 # 无声契拒绝
|
||||
consent = await client.post(
|
||||
"/v1/voice-consents", json={"purpose": "voice_clone"}, headers=headers
|
||||
)
|
||||
assert consent.status_code == 201
|
||||
ok = await client.post(
|
||||
"/v1/media/jobs",
|
||||
json={"kind": "tts", "input": {"text": "你好"}}, headers=headers
|
||||
)
|
||||
assert ok.status_code == 201
|
||||
# 撤回后再次拒绝
|
||||
revoke = await client.post(
|
||||
f"/v1/voice-consents/{consent.json()['id']}/revocations", headers=headers
|
||||
)
|
||||
assert revoke.json()["revoked_at"] is not None
|
||||
after = await client.post(
|
||||
"/v1/media/jobs",
|
||||
json={"kind": "tts", "input": {"text": "再见"}}, headers=headers
|
||||
)
|
||||
assert after.status_code == 403
|
||||
Reference in new issue
Block a user