24b28bfc89
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 / 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 / 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 / PR Build API Image (pull_request) Successful in 17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m16s
AI Code Review / AI Code Review (pull_request) Successful in 6m23s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m28s
- 新建 ScriptModel (packages/adapters/sqlalchemy_impl/models.py) 字段: id, user_id(indexed), title, content, segments(JSON), tags(JSON), created_at, updated_at - 新建 script_service.py: CRUD 封装,用户隔离,tag 筛选,分页 - 新建 schemas/script.py: Pydantic request/response schemas - 新建 routes/scripts.py: RESTful API (GET/POST/PUT/DELETE /api/v1/scripts) - 新建 alembic 070_add_scripts_table.py: scripts 表 + user_id 索引 - 注册路由到 router.py (prefix=/scripts, tag=ScriptLibrary) - 35 单元测试: service 15 + schema/route 20
261 lines
9.7 KiB
Python
261 lines
9.7 KiB
Python
"""ScriptService 单元测试 — Issue #1795 口播文案库.
|
|
|
|
CI 增量映射: script_service.py → test_script_service.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from app.services.script_service import ScriptNotFoundError, ScriptService
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_mock_script(
|
|
script_id="s1",
|
|
user_id="u1",
|
|
title="测试文案",
|
|
content="正文内容",
|
|
segments=None,
|
|
tags=None,
|
|
):
|
|
m = MagicMock()
|
|
m.id = script_id
|
|
m.user_id = user_id
|
|
m.title = title
|
|
m.content = content
|
|
m.segments = segments if segments is not None else [{"text": "第一段", "duration": None}]
|
|
m.tags = tags if tags is not None else ["口播"]
|
|
m.created_at = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc)
|
|
m.updated_at = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc)
|
|
return m
|
|
|
|
|
|
def _make_service(db=None):
|
|
if db is None:
|
|
db = MagicMock()
|
|
return ScriptService(db), db
|
|
|
|
|
|
# ── create ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCreateScript:
|
|
def test_create_minimal(self):
|
|
svc, db = _make_service()
|
|
# query chain for get_script (not called here but add mock anyway)
|
|
with patch("app.services.script_service.ScriptModel") as MockModel:
|
|
instance = _make_mock_script()
|
|
MockModel.return_value = instance
|
|
result = svc.create_script(user_id="u1", title="测试文案")
|
|
# ScriptModel was called to create a new instance
|
|
MockModel.assert_called_once()
|
|
db.add.assert_called_once()
|
|
db.commit.assert_called_once()
|
|
db.refresh.assert_called_once()
|
|
|
|
def test_create_with_segments_and_tags(self):
|
|
svc, db = _make_service()
|
|
segments = [{"text": "第一段", "duration": 5.0}, {"text": "第二段", "duration": None}]
|
|
tags = ["口播", "教程"]
|
|
with patch("app.services.script_service.ScriptModel") as MockModel:
|
|
instance = _make_mock_script(segments=segments, tags=tags)
|
|
MockModel.return_value = instance
|
|
result = svc.create_script(
|
|
user_id="u1",
|
|
title="分段文案",
|
|
content="完整内容",
|
|
segments=segments,
|
|
tags=tags,
|
|
)
|
|
db.add.assert_called_once()
|
|
call_kwargs = MockModel.call_args
|
|
assert call_kwargs[1]["segments"] == segments
|
|
assert call_kwargs[1]["tags"] == tags
|
|
|
|
def test_create_defaults_empty_segments_tags(self):
|
|
svc, db = _make_service()
|
|
with patch("app.services.script_service.ScriptModel") as MockModel:
|
|
MockModel.return_value = _make_mock_script()
|
|
svc.create_script(user_id="u1", title="空文案")
|
|
call_kwargs = MockModel.call_args[1]
|
|
assert call_kwargs["segments"] == []
|
|
assert call_kwargs["tags"] == []
|
|
|
|
|
|
# ── get ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestGetScript:
|
|
def test_get_existing(self):
|
|
svc, db = _make_service()
|
|
mock_script = _make_mock_script()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = mock_script
|
|
db.query.return_value = chain
|
|
|
|
result = svc.get_script("s1", "u1")
|
|
assert result == mock_script
|
|
# Verify filter was called with correct conditions
|
|
assert chain.filter.called
|
|
|
|
def test_get_not_found_raises(self):
|
|
svc, db = _make_service()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = None
|
|
db.query.return_value = chain
|
|
|
|
with pytest.raises(ScriptNotFoundError):
|
|
svc.get_script("nonexistent", "u1")
|
|
|
|
def test_get_wrong_user_raises(self):
|
|
"""不同用户不能访问其他人的文案."""
|
|
svc, db = _make_service()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = None # filter by user_id returns None
|
|
db.query.return_value = chain
|
|
|
|
with pytest.raises(ScriptNotFoundError):
|
|
svc.get_script("s1", "other_user")
|
|
|
|
|
|
# ── list ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestListScripts:
|
|
def test_list_default(self):
|
|
svc, db = _make_service()
|
|
items = [_make_mock_script("s1"), _make_mock_script("s2")]
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.count.return_value = 2
|
|
chain.order_by.return_value = chain
|
|
chain.offset.return_value = chain
|
|
chain.limit.return_value = chain
|
|
chain.all.return_value = items
|
|
db.query.return_value = chain
|
|
|
|
result, total = svc.list_scripts("u1")
|
|
assert total == 2
|
|
assert len(result) == 2
|
|
chain.offset.assert_called_with(0)
|
|
chain.limit.assert_called_with(50)
|
|
|
|
def test_list_with_pagination(self):
|
|
svc, db = _make_service()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.count.return_value = 100
|
|
chain.order_by.return_value = chain
|
|
chain.offset.return_value = chain
|
|
chain.limit.return_value = chain
|
|
chain.all.return_value = []
|
|
db.query.return_value = chain
|
|
|
|
result, total = svc.list_scripts("u1", skip=20, limit=10)
|
|
chain.offset.assert_called_with(20)
|
|
chain.limit.assert_called_with(10)
|
|
|
|
def test_list_filter_by_tag(self):
|
|
svc, db = _make_service()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.count.return_value = 1
|
|
chain.order_by.return_value = chain
|
|
chain.offset.return_value = chain
|
|
chain.limit.return_value = chain
|
|
chain.all.return_value = [_make_mock_script()]
|
|
db.query.return_value = chain
|
|
|
|
result, total = svc.list_scripts("u1", tag="口播")
|
|
# filter should be called twice: once for user_id, once for tag
|
|
assert chain.filter.call_count == 2
|
|
|
|
|
|
# ── update ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestUpdateScript:
|
|
def test_update_title(self):
|
|
svc, db = _make_service()
|
|
mock_script = _make_mock_script()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = mock_script
|
|
db.query.return_value = chain
|
|
|
|
result = svc.update_script("s1", "u1", title="新标题")
|
|
assert mock_script.title == "新标题"
|
|
db.commit.assert_called_once()
|
|
|
|
def test_update_segments(self):
|
|
svc, db = _make_service()
|
|
mock_script = _make_mock_script()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = mock_script
|
|
db.query.return_value = chain
|
|
|
|
new_segments = [{"text": "更新后段落", "duration": 10.0}]
|
|
result = svc.update_script("s1", "u1", segments=new_segments)
|
|
assert mock_script.segments == new_segments
|
|
|
|
def test_update_not_found_raises(self):
|
|
svc, db = _make_service()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = None
|
|
db.query.return_value = chain
|
|
|
|
with pytest.raises(ScriptNotFoundError):
|
|
svc.update_script("nonexistent", "u1", title="x")
|
|
|
|
def test_update_partial_only_changes_specified(self):
|
|
svc, db = _make_service()
|
|
mock_script = _make_mock_script(title="原标题", content="原内容")
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = mock_script
|
|
db.query.return_value = chain
|
|
|
|
# Only update tags, title and content should stay the same
|
|
svc.update_script("s1", "u1", tags=["新标签"])
|
|
assert mock_script.title == "原标题"
|
|
assert mock_script.content == "原内容"
|
|
assert mock_script.tags == ["新标签"]
|
|
|
|
|
|
# ── delete ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDeleteScript:
|
|
def test_delete_existing(self):
|
|
svc, db = _make_service()
|
|
mock_script = _make_mock_script()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = mock_script
|
|
db.query.return_value = chain
|
|
|
|
result = svc.delete_script("s1", "u1")
|
|
assert result is True
|
|
db.delete.assert_called_once_with(mock_script)
|
|
db.commit.assert_called_once()
|
|
|
|
def test_delete_not_found(self):
|
|
svc, db = _make_service()
|
|
chain = MagicMock()
|
|
chain.filter.return_value = chain
|
|
chain.first.return_value = None
|
|
db.query.return_value = chain
|
|
|
|
result = svc.delete_script("nonexistent", "u1")
|
|
assert result is False
|
|
db.delete.assert_not_called()
|