fix(scripts): #1894/#1968 抖音 yt-dlp cookies 支持 + 旧档位归一化补测 + CI 覆盖率修复
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 / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker 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 / Check if frontend-only change (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m28s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m23s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
AI Code Review / AI Code Review (pull_request) Successful in 6m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m42s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Failing after 14m49s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m30s
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
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

- scripts_ai.py: 支持通过 DOUYIN_COOKIES_FILE 指定 Netscape cookies 文件;
  cookies 缺失/过期类错误('fresh cookies' 等)返回 503 + 友好文案
  ('抖音链接解析暂时不可用,请稍后重试或手动输入文案'),
  不再暴露 yt-dlp 原始错误;普通下载失败统一文案,不带内部异常文本
- ci_staging_deploy.sh: API 容器挂载 /var/lib/xiaoxia-saas-staging/configs/douyin_cookies.txt
  到 /app/configs/douyin_cookies.txt;自动 mkdir + 空占位兜底
- infra/docker/compose.yml: API 服务挂载 ../../deploy/configs:/app/configs:ro
- deploy/configs/.env.{staging,production}: 新增 DOUYIN_COOKIES_FILE 环境变量
- deploy/configs/douyin_cookies.txt: Netscape 格式占位文件(真实 cookies 部署时替换)
- 新增 test_helpers_get_user_plan.py 覆盖 _helpers.get_user_plan 旧档位归一化分支
- 新增 test_subscription_routes_legacy.py 覆盖 _build_subscription_info 旧档位归一化/
  cancel_subscription 免费用户分支/change_plan 参数校验/toggle-auto-renew 等
- 扩展 test_extract_from_douyin_errors.py: 新增 5 个 cookies 场景测试(503 友好文案/
  cookiefile 注入/错误信息脱敏等)
- 增量覆盖率从 59% 提升到 60%+,解决 CI Gate 阻塞
This commit is contained in:
2026-09-16 18:28:23 +08:00
parent 02c7bba247
commit d855fc30ba
9 changed files with 472 additions and 7 deletions
@@ -220,3 +220,126 @@ def test_any_unexpected_error_does_not_return_500_raw(fake_user):
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
# 只要不是被全局 INTERNAL_ERROR 吞掉就行(带 detail 的 500 也比通用 500 强)
assert "抖音" in exc.value.detail or "失败" in exc.value.detail or exc.value.status_code != 500
# ── cookies 相关测试 ──────────────────────────────────────────────
def test_cookies_error_returns_503_friendly_message(fake_user):
"""cookies 缺失/过期(yt-dlp 报 'Fresh cookies ... are needed')→ 返回 503 + 友好文案,不暴露原始错误"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/")
class DownloadError(Exception):
pass
class CookiesYDL(_FakeYDLBase):
extract_info_raises = DownloadError(
"ERROR: [Douyin] 7623712911260650802: Fresh cookies (not necessarily logged in) are needed"
)
_install_fake_ytdlp(CookiesYDL, download_error_cls=DownloadError)
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE, f"应为503,实际 {exc.value.status_code}"
assert "暂时不可用" in exc.value.detail or "稍后重试" in exc.value.detail, f"应有友好提示,实际: {exc.value.detail}"
# 确认不暴露 yt-dlp 原始错误
assert "Fresh cookies" not in exc.value.detail
def test_cookies_error_in_generic_except_also_returns_503(fake_user):
"""cookies 错误绕过 DownloadError(例如被其他异常包装)时,兜底异常分支也应识别并返回 503"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class CookieBugYDL(_FakeYDLBase):
def extract_info(self, url, download=True):
# 抛非 DownloadError 的普通异常,但 message 含 cookies 关键词
raise RuntimeError("Fresh cookies are needed to access this video")
_install_fake_ytdlp(CookieBugYDL)
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
def test_ydl_opts_includes_cookiefile_when_file_exists(fake_user):
"""cookies 文件存在时,ydl_opts 应包含 cookiefile 指向该路径"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
captured_opts = {}
class CaptureOptsYDL(_FakeYDLBase):
def __init__(self, opts):
captured_opts.update(opts)
super().__init__()
extract_info_result = {"id": "x", "duration": 5, "title": "t"}
_install_fake_ytdlp(CaptureOptsYDL)
with (
mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)),
mock.patch.object(scripts_ai, "_cookies_file_exists", return_value=True),
mock.patch.object(scripts_ai, "DOUYIN_COOKIES_FILE", "/tmp/fake_cookies.txt"),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", return_value="ok"),
):
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert captured_opts.get("cookiefile") == "/tmp/fake_cookies.txt", f"cookiefile 应被设置,opts={captured_opts}"
def test_ydl_opts_no_cookiefile_when_file_missing(fake_user):
"""cookies 文件不存在时,ydl_opts 不应包含 cookiefile 键"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
captured_opts = {}
class CaptureOptsYDL(_FakeYDLBase):
def __init__(self, opts):
captured_opts.update(opts)
super().__init__()
extract_info_result = {"id": "x", "duration": 5, "title": "t"}
_install_fake_ytdlp(CaptureOptsYDL)
with (
mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)),
mock.patch.object(scripts_ai, "_cookies_file_exists", return_value=False),
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True),
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024),
mock.patch.object(scripts_ai, "transcribe_to_text", return_value="ok"),
):
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert "cookiefile" not in captured_opts, f"cookies 文件缺失时不应设置 cookiefile,opts={captured_opts}"
def test_generic_download_error_hides_raw_message(fake_user):
"""非 cookies 非 404 的通用下载错误 → 502,且不暴露 yt-dlp 原始错误文本"""
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
class DownloadError(Exception):
pass
class GenErrYDL(_FakeYDLBase):
extract_info_raises = DownloadError("ERROR: some internal yt-dlp weird failure with trace")
_install_fake_ytdlp(GenErrYDL, download_error_cls=DownloadError)
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
assert "下载失败" in exc.value.detail
assert "weird failure" not in exc.value.detail, "不应暴露 yt-dlp 内部错误文本"
+72
View File
@@ -0,0 +1,72 @@
"""验证 _helpers.get_user_plan 档位归一化逻辑(#1894 旧档位兼容)"""
from __future__ import annotations
from unittest import mock
import pytest
from app.api.routes import _helpers
class _FakeUser:
def __init__(self, plan):
self.subscription_plan = plan
class _FakeUserNoPlan:
pass
class _FakeRepo:
def __init__(self, user=None):
self._user = user
def find_by_id(self, uid):
return self._user
def test_user_not_found_returns_free():
"""用户不存在时返回 free(覆盖 _helpers.py 第 41 行 user is None 分支)"""
repo = _FakeRepo(user=None)
assert _helpers.get_user_plan("u-missing", repo) == "free"
def test_user_plan_none_returns_free():
"""用户 plan 属性为 None 时返回 free"""
repo = _FakeRepo(user=_FakeUser(None))
assert _helpers.get_user_plan("u1", repo) == "free"
@pytest.mark.parametrize(
"legacy,expected",
[
("standard", "monthly"),
("basic", "monthly"),
("pro", "quarterly"),
("premium", "quarterly"),
("enterprise", "quarterly"),
],
)
def test_legacy_plans_normalized(legacy, expected):
"""旧档位值正确归一化到新体系"""
repo = _FakeRepo(user=_FakeUser(legacy))
assert _helpers.get_user_plan("u1", repo) == expected
def test_unknown_plan_returns_free():
"""未知 plan 值(非新旧任一档位)→ 回落到 free(覆盖第 47 行)"""
repo = _FakeRepo(user=_FakeUser("totally_unknown_plan_xyz"))
assert _helpers.get_user_plan("u1", repo) == "free"
def test_user_without_subscription_plan_attr_returns_free():
"""user 对象没有 subscription_plan 属性时返回 free(getattr 默认值分支)"""
repo = _FakeRepo(user=_FakeUserNoPlan())
assert _helpers.get_user_plan("u1", repo) == "free"
@pytest.mark.parametrize("plan", ["free", "monthly", "quarterly", "yearly"])
def test_valid_new_plans_passthrough(plan):
"""新档位直接透传"""
repo = _FakeRepo(user=_FakeUser(plan))
assert _helpers.get_user_plan("u1", repo) == plan
@@ -0,0 +1,185 @@
"""#1894 旧档位归一化逻辑测试(覆盖 _build_subscription_info / change_plan / cancel 等分支)"""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from unittest import mock
import pytest
from app.api.routes import subscription
from app.auth import AuthenticatedUser
from app.schemas.subscription import (
BillingCycle,
ChangePlanRequest,
MembershipType,
ToggleAutoRenewRequest,
)
@dataclass
class _FakeUserModel:
id: str = "u-1234567890"
subscription_plan: str | None = MembershipType.FREE
subscription_status: str | None = "active"
subscription_expires_at: datetime | None = None
created_at: datetime | None = None
@pytest.fixture
def auth_user():
return AuthenticatedUser(user=_FakeUserModel())
class TestBuildSubscriptionInfoLegacy:
"""覆盖 _build_subscription_info 旧档位归一化(subscription.py 57-58 行)"""
def test_legacy_standard_plan_normalized_to_monthly(self, auth_user):
new_user = replace(auth_user.user, subscription_plan="standard")
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
assert info.plan_id == MembershipType.MONTHLY
assert info.plan_name == "月卡会员"
def test_legacy_pro_plan_normalized_to_monthly(self, auth_user):
new_user = replace(auth_user.user, subscription_plan="pro")
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
# 旧 pro/standard/enterprise 都归一化到 monthly(按代码逻辑 {standard,pro,enterprise} → monthly)
assert info.plan_id == MembershipType.MONTHLY
def test_legacy_enterprise_plan_normalized(self, auth_user):
new_user = replace(auth_user.user, subscription_plan="enterprise")
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
assert info.plan_id == MembershipType.MONTHLY
def test_no_expiry_gives_now_period(self, auth_user):
"""无过期时间时 period_start 和 period_end 都为 now(覆盖 else 分支 55-56 行)"""
new_user = replace(auth_user.user, subscription_expires_at=None)
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
# 两个时间都应非空且接近当前时间
assert info.current_period_start
assert info.current_period_end
def test_free_user_billing_cycle_defaults_to_monthly(self, auth_user):
"""免费用户 billing_cycle 回落到 monthly(覆盖第 64 行 !=FREE 判定 else 分支)"""
new_user = replace(auth_user.user, subscription_plan=MembershipType.FREE)
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
assert info.billing_cycle == BillingCycle.MONTHLY
assert info.amount == 0
def test_yearly_user_passthrough(self, auth_user):
"""yearly 用户档位直接透传"""
new_user = replace(auth_user.user, subscription_plan=MembershipType.YEARLY)
auth_user = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user)
assert info.plan_id == MembershipType.YEARLY
assert info.plan_name == "年卡会员"
class TestChangePlanValidation:
"""覆盖 change_plan 入参校验 / 同档位提示 / 旧档位归一化"""
@pytest.mark.asyncio
async def test_invalid_plan_returns_400(self, auth_user):
"""无效 plan_id → 400(覆盖 169 行)"""
from fastapi import HTTPException
req = ChangePlanRequest(target_plan_id="totally_bogus_plan", billing_cycle=BillingCycle.MONTHLY)
with pytest.raises(HTTPException) as exc:
await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
assert exc.value.status_code == 400
assert "无效" in exc.value.detail
@pytest.mark.asyncio
async def test_invalid_billing_cycle_returns_400(self, auth_user):
"""无效 billing_cycle → 400(覆盖 176/178-179 行)"""
from fastapi import HTTPException
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle="bogus_cycle")
with pytest.raises(HTTPException) as exc:
await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_same_plan_returns_message(self, auth_user):
"""同档位变更 → 返回提示(覆盖 185 行分支)"""
new_user = replace(auth_user.user, subscription_plan=MembershipType.MONTHLY)
auth_user = replace(auth_user, user=new_user)
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle=BillingCycle.MONTHLY)
resp = await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
assert resp.success is False
assert "已经是" in resp.message
@pytest.mark.asyncio
async def test_legacy_plan_normalized_for_same_plan_check(self, auth_user):
"""旧档位用户升级到 monthly → 应先归一化 current_plan 到 monthly,再判定为'同档位'"""
new_user = replace(auth_user.user, subscription_plan="standard")
auth_user = replace(auth_user, user=new_user)
# legacy → monthly
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle=BillingCycle.MONTHLY)
resp = await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
# standard 归一化到 monthly,所以 target monthly == current monthly → same plan
assert resp.success is False
class TestCancelSubscription:
"""覆盖 cancel_subscription 分支"""
@pytest.mark.asyncio
async def test_cancel_free_user_returns_400(self, auth_user):
"""免费用户取消订阅 → 400(覆盖 252 行)"""
from fastapi import HTTPException
new_user = replace(auth_user.user, subscription_plan=MembershipType.FREE)
auth_user = replace(auth_user, user=new_user)
with pytest.raises(HTTPException) as exc:
await subscription.cancel_subscription(current_user=auth_user, user_repository=mock.MagicMock())
assert exc.value.status_code == 400
assert "免费" in exc.value.detail
@pytest.mark.asyncio
async def test_cancel_paid_user_marks_cancelled(self, auth_user):
"""付费用户取消订阅 → save 被调用且 subscription_status='cancelled'"""
new_user = replace(auth_user.user, subscription_plan=MembershipType.MONTHLY)
auth_user = replace(auth_user, user=new_user)
repo = mock.MagicMock()
resp = await subscription.cancel_subscription(current_user=auth_user, user_repository=repo)
assert resp.success is True
repo.save.assert_called_once()
saved_user = repo.save.call_args[0][0]
assert saved_user.subscription_status == "cancelled"
class TestToggleAutoRenew:
"""覆盖 toggle_auto_renew"""
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled,msg", [(True, "已开启"), (False, "已关闭")])
async def test_toggle_returns_message(self, auth_user, enabled, msg):
req = ToggleAutoRenewRequest(enabled=enabled)
resp = await subscription.toggle_auto_renew(request=req, current_user=auth_user)
assert resp.success is True
assert msg in resp.message
class TestBuildSubscriptionInfoEdgeCases:
"""覆盖 _build_subscription_info 的边缘分支"""
def test_created_at_none_uses_now(self, auth_user):
"""user.created_at 为 None 时,created_at 字段回落到 now.isoformat(覆盖 69 行)"""
new_user = replace(auth_user.user, created_at=None, subscription_plan=MembershipType.MONTHLY)
auth_user2 = replace(auth_user, user=new_user)
info = subscription._build_subscription_info(auth_user2)
assert info.created_at # 非空
# 应为 ISO 格式字符串
from datetime import datetime
# 能解析即通过
datetime.fromisoformat(info.created_at)