fix(#1894): add alembic 077 migration; fix test imports (from main import app) + URL prefix + dependency_overrides; ASCII Warning header
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 7m17s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (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) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 18h1m41s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 18h1m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 18h1m5s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 18h1m10s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 18h1m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 18h1m12s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 18h1m15s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 18h1m15s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 18h1m16s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 18h0m59s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 18h0m59s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 18h1m13s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 18h1m14s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 18h36m50s

This commit is contained in:
xiaoxia
2026-09-15 20:09:04 +08:00
parent b64d384b91
commit 509b8db3a3
3 changed files with 150 additions and 56 deletions
@@ -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")
+6 -3
View File
@@ -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:
+80 -53
View File
@@ -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()