From 509b8db3a3028aa03a45dbd4e5b75e49aac4b038 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 15 Sep 2026 20:09:04 +0800 Subject: [PATCH] fix(#1894): add alembic 077 migration; fix test imports (from main import app) + URL prefix + dependency_overrides; ASCII Warning header --- .../077_merge_title_library_into_scripts.py | 64 +++++++++ apps/api/app/api/routes/titles.py | 9 +- tests/unit/test_script_title_integration.py | 133 +++++++++++------- 3 files changed, 150 insertions(+), 56 deletions(-) create mode 100644 alembic/versions/077_merge_title_library_into_scripts.py diff --git a/alembic/versions/077_merge_title_library_into_scripts.py b/alembic/versions/077_merge_title_library_into_scripts.py new file mode 100644 index 000000000..2e7fe8e58 --- /dev/null +++ b/alembic/versions/077_merge_title_library_into_scripts.py @@ -0,0 +1,64 @@ +"""#1894: merge title_library into scripts — add title_text/title_category/title_config to scripts + +Revision ID: 077_merge_title_library_into_scripts +Revises: 076_membership_points +Create Date: 2026-09-15 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "077_merge_title_library_into_scripts" +down_revision = "076_membership_points" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # scripts 表新增标题配置三字段 + with op.batch_alter_table("scripts") as batch: + batch.add_column( + sa.Column("title_text", sa.String(500), nullable=False, server_default=""), + ) + batch.add_column( + sa.Column("title_category", sa.String(50), nullable=False, server_default=""), + ) + batch.add_column( + sa.Column("title_config", sa.JSON, nullable=False, server_default="{}"), + ) + + # 从 title_libraries 迁移活跃数据到 scripts(若 title_libraries 表存在) + conn = op.get_bind() + exists = conn.execute(sa.text("SELECT to_regclass('public.title_libraries') IS NOT NULL")).scalar() + if exists: + conn.execute(sa.text(""" + INSERT INTO scripts (id, user_id, title, content, segments, tags, title_text, title_category, title_config, created_at, updated_at) + SELECT + gen_random_uuid()::TEXT, + tl.user_id, + COALESCE(tl.name, '迁移标题'), + COALESCE(tl.text, ''), + '[]'::JSONB, + COALESCE(tl.tags, '[]'::JSONB), + COALESCE(tl.text, ''), + COALESCE(tl.category, ''), + COALESCE(tl."metadata", '{}'::JSONB), + tl.created_at, + tl.updated_at + FROM title_libraries tl + WHERE tl.is_active = true + AND NOT EXISTS ( + SELECT 1 FROM scripts s + WHERE s.user_id = tl.user_id + AND s.title_text = COALESCE(tl.text, '') + AND s.title_category = COALESCE(tl.category, '') + AND s.created_at = tl.created_at + ) + """)) + + +def downgrade() -> None: + with op.batch_alter_table("scripts") as batch: + batch.drop_column("title_config") + batch.drop_column("title_category") + batch.drop_column("title_text") diff --git a/apps/api/app/api/routes/titles.py b/apps/api/app/api/routes/titles.py index cf8d7b203..1a6d1ada5 100755 --- a/apps/api/app/api/routes/titles.py +++ b/apps/api/app/api/routes/titles.py @@ -43,12 +43,15 @@ from packages.ports.user_repository import UserRepository router = APIRouter() logger = logging.getLogger(__name__) -_DEPRECATION_WARNING = "标题库 API 已废弃,请迁移到 scripts 的 title_text/title_category/title_config 字段(#1894)" +_DEPRECATION_WARNING = ( + '299 - "Title library API is deprecated; migrate to scripts.title_text/' + 'title_category/title_config (issue #1894)"' +) def _deprecation_headers() -> dict: - """返回 deprecation Warning header.""" - return {"Warning": f'299 - {_DEPRECATION_WARNING}'} + """返回 deprecation Warning header (ASCII-only, RFC 7234 §5.5).""" + return {"Warning": _DEPRECATION_WARNING, "Deprecation": "true"} def _log_deprecation(endpoint: str) -> None: diff --git a/tests/unit/test_script_title_integration.py b/tests/unit/test_script_title_integration.py index f7320007b..a1a6945e6 100644 --- a/tests/unit/test_script_title_integration.py +++ b/tests/unit/test_script_title_integration.py @@ -10,14 +10,24 @@ from __future__ import annotations +import os +import sys import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime, timezone +from pathlib import Path from unittest.mock import MagicMock, patch import pytest -from app.main import app from fastapi.testclient import TestClient +# 确保 apps/api 在 sys.path 中(conftest 已加 root,但 apps/api 也需要) +_APPS_API = str(Path(__file__).resolve().parents[2] / "apps" / "api") +if _APPS_API not in sys.path: + sys.path.insert(0, _APPS_API) +os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing") + +from main import app # noqa: E402 + # ── helpers ────────────────────────────────────────────────────────────── @@ -197,25 +207,43 @@ class TestScriptServiceCreateWithNewFields: # ── TestScriptsRoutesNewFields ─────────────────────────────────────────── -class TestScriptsRoutesNewFields: - """验证 scripts API 路由正确处理新字段.""" +def _make_mock_auth_user(user_id="user-001"): + """创建 mock 认证用户.""" + return MagicMock(user=MagicMock(id=user_id)) - @patch("app.api.routes.scripts._get_service") - @patch("app.auth.get_current_user") - def test_create_script_passes_title_fields(self, mock_auth, mock_svc_dep): - mock_auth.return_value = MagicMock(user=MagicMock(id="user-001")) + +class TestScriptsRoutesNewFields: + """验证 scripts API 路由正确处理新字段 — 使用 dependency_overrides 绕过真实 DB/Auth.""" + + def setup_method(self): + from app.api.routes.scripts import _get_service, get_current_user + + self._mock_svc = MagicMock() + self._mock_user = _make_mock_auth_user() + + def _override_svc(): + return self._mock_svc + + def _override_user(): + return self._mock_user + + app.dependency_overrides[_get_service] = _override_svc + app.dependency_overrides[get_current_user] = _override_user + self.client = TestClient(app) + + def teardown_method(self): + app.dependency_overrides.clear() + + def test_create_script_passes_title_fields(self): mock_script = _make_script( title_text="测试标题", title_category="片头", title_config={"text": "测试标题", "font_size": 48}, ) - mock_svc = MagicMock() - mock_svc.create_script.return_value = mock_script - mock_svc_dep.return_value = mock_svc + self._mock_svc.create_script.return_value = mock_script - client = TestClient(app) - resp = client.post( - "/scripts", + resp = self.client.post( + "/api/v1/scripts", json={ "title": "新文案", "content": "内容", @@ -223,33 +251,25 @@ class TestScriptsRoutesNewFields: "title_category": "片头", "title_config": {"text": "测试标题", "font_size": 48}, }, - headers={"X-User-Id": "user-001"}, ) - assert resp.status_code == 201 - # 验证 service 被调用时传入了新字段 - call_kwargs = mock_svc.create_script.call_args[1] + assert resp.status_code == 201, resp.text + call_kwargs = self._mock_svc.create_script.call_args[1] assert call_kwargs["title_text"] == "测试标题" assert call_kwargs["title_category"] == "片头" assert call_kwargs["title_config"] == {"text": "测试标题", "font_size": 48} - @patch("app.api.routes.scripts._get_service") - @patch("app.auth.get_current_user") - def test_get_script_response_includes_title_fields(self, mock_auth, mock_svc_dep): - mock_auth.return_value = MagicMock(user=MagicMock(id="user-001")) + def test_get_script_response_includes_title_fields(self): mock_script = _make_script( title_text="响应标题", title_category="片尾", title_config={"text": "响应标题", "position": "bottom"}, ) - mock_svc = MagicMock() - mock_svc.get_script.return_value = mock_script - mock_svc_dep.return_value = mock_svc + self._mock_svc.get_script.return_value = mock_script - client = TestClient(app) - resp = client.get("/scripts/script-123", headers={"X-User-Id": "user-001"}) + resp = self.client.get("/api/v1/scripts/script-123") - assert resp.status_code == 200 + assert resp.status_code == 200, resp.text data = resp.json() assert data["title_text"] == "响应标题" assert data["title_category"] == "片尾" @@ -260,26 +280,37 @@ class TestScriptsRoutesNewFields: class TestTitleLibraryDeprecated: - """验证 title_libraries API 返回 deprecated Warning header.""" + """验证 title_libraries API 返回 deprecated Warning header — 使用 dependency_overrides 绕过真实 DB/Auth.""" - @patch("app.api.routes.titles._get_title_repository") - @patch("app.auth.get_current_user") - def test_list_titles_has_warning_header(self, mock_auth, mock_repo_dep): - mock_auth.return_value = MagicMock(user=MagicMock(id="user-001")) - mock_repo = MagicMock() - mock_repo.count_by_user.return_value = 0 - mock_repo_dep.return_value = mock_repo + def setup_method(self): + from app.api.routes.titles import _get_title_repository, get_current_user + from app.dependencies import get_user_repository - client = TestClient(app) - resp = client.get("/titles", headers={"X-User-Id": "user-001"}) + self._mock_repo = MagicMock() + self._mock_user_repo = MagicMock() + self._mock_user = _make_mock_auth_user() - assert resp.status_code == 200 - assert "Warning" in resp.headers or "warning" in {k.lower() for k in resp.headers} + app.dependency_overrides[_get_title_repository] = lambda: self._mock_repo + app.dependency_overrides[get_user_repository] = lambda: self._mock_user_repo + app.dependency_overrides[get_current_user] = lambda: self._mock_user + self.client = TestClient(app) - @patch("app.api.routes.titles._get_title_repository") - @patch("app.auth.get_current_user") - def test_get_title_has_warning_header(self, mock_auth, mock_repo_dep): - mock_auth.return_value = MagicMock(user=MagicMock(id="user-001")) + def teardown_method(self): + app.dependency_overrides.clear() + + def test_list_titles_has_warning_header(self): + # list_titles 调 use_case + repo, 注入真实用例但 mock 掉 repo 的 list/count + self._mock_repo.list_by_user.return_value = [] + self._mock_repo.count_by_user.return_value = 0 + + resp = self.client.get("/api/v1/titles") + + assert resp.status_code == 200, resp.text + headers_lower = {k.lower(): v for k, v in resp.headers.items()} + assert "warning" in headers_lower or "deprecation" in headers_lower + assert "1894" in resp.headers.get("Warning", "") or "1894" in resp.headers.get("warning", "") + + def test_get_title_has_warning_header(self): from packages.domain.title_library import TitleLibraryItem mock_item = TitleLibraryItem( @@ -295,13 +326,9 @@ class TestTitleLibraryDeprecated: created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), ) - mock_repo = MagicMock() - mock_repo_dep.return_value = mock_repo + self._mock_repo.get.return_value = mock_item - with patch("packages.application.title_library.use_cases.GetTitleLibraryUseCase") as mock_uc: - mock_uc.return_value.execute.return_value = mock_item - client = TestClient(app) - resp = client.get("/titles/t1", headers={"X-User-Id": "user-001"}) - assert resp.status_code == 200 - warning_header = resp.headers.get("Warning", "") - assert "1894" in warning_header or "deprecated" in warning_header.lower() or "废弃" in warning_header + resp = self.client.get("/api/v1/titles/t1") + assert resp.status_code == 200, resp.text + warning_header = resp.headers.get("Warning", "") or resp.headers.get("warning", "") + assert "1894" in warning_header or "deprecated" in warning_header.lower()