Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ca2ffe272 | |||
| dfb2feef8a | |||
| b3ef7bb041 | |||
| a1a272b833 | |||
| 728db0faf8 | |||
| 7e5e412f7f | |||
| df08161630 | |||
| 0c9375ff32 | |||
| ef344e9ffc | |||
| 0d4904433e | |||
| 708662394f | |||
| 9b034764ad | |||
| 8748b43070 | |||
| d213a055a1 | |||
| 2371860f82 | |||
| dbd956fc6e | |||
| 1d59ee5336 |
@@ -0,0 +1,165 @@
|
||||
name: Auto Approve CI PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
# 根据目标分支决定检查哪些门禁
|
||||
TARGET_BRANCH="${GITHUB_BASE_REF}"
|
||||
echo "目标分支: ${TARGET_BRANCH}"
|
||||
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
elif [ "$TARGET_BRANCH" = "main" ]; then
|
||||
# main分支只检查required statuses: Validate + Frontend Lint
|
||||
# 不检查Tests/test(不是required门禁)
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"Tests / test (pull_request)"
|
||||
)
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 120); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review(Gitea API需要先创建再提交)
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
+119
-14
@@ -1,26 +1,131 @@
|
||||
name: Auto Merge PRs
|
||||
name: Auto Merge PRs (main)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: saas
|
||||
timeout-minutes: 10
|
||||
name: Auto Merge on CI Green + Approved (main)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'main'
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
set -eu
|
||||
bash scripts/ci_checkout.sh
|
||||
- name: Auto merge develop PRs
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh develop
|
||||
|
||||
- name: Auto merge main PRs (release only)
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh main
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合main分支
|
||||
if [ "$BASE_REF" != "main" ]; then
|
||||
echo "Skip: 目标分支不是main"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# main分支门禁:Validate + Frontend Lint
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"Tests / test (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
echo "检查门禁: ${#CONTEXTS[@]} 项"
|
||||
echo
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 180); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查审批状态
|
||||
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
|
||||
echo " 审批: $APPROVAL_RESULT"
|
||||
HAS_APPROVAL=false
|
||||
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
|
||||
HAS_APPROVAL=true
|
||||
fi
|
||||
|
||||
# 全部满足 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿 + 审批通过,执行自动合并"
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行merge(main分支用merge,保留历史)
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"merge","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
echo "合并失败(405),可能有冲突或门禁未通过"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge failed: PR may have conflicts or unresolved checks. Please review manually."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
exit 0
|
||||
|
||||
+246
-212
File diff suppressed because one or more lines are too long
@@ -1,550 +0,0 @@
|
||||
name: Daily Health Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨 3:00
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
||||
production-smoke:
|
||||
name: Production Smoke Test
|
||||
runs-on: saas
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
bash scripts/ci_checkout.sh
|
||||
- name: Production health check & smoke test
|
||||
id: smoke
|
||||
shell: sh
|
||||
env:
|
||||
SMOKE_ENV: production
|
||||
EXISTING_TOKEN: ${{ secrets.PROD_E2E_TOKEN }}
|
||||
MODULES: health,assets,generation,subscription,nginx
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
chmod +x tests/e2e/api_smoke_test.sh
|
||||
BASE_URL="https://api.xiaoxiajianji.com" \
|
||||
WEB_URL="https://saas.xiaoxiajianji.com" \
|
||||
SMOKE_ENV="${SMOKE_ENV}" \
|
||||
EXISTING_TOKEN="${EXISTING_TOKEN}" \
|
||||
MODULES="${MODULES}" \
|
||||
CLEANUP_ENABLED=0 \
|
||||
PERF_CHECK_ENABLED=1 \
|
||||
PERF_WARN_THRESHOLD_MS=500 \
|
||||
PERF_FAIL_THRESHOLD_MS=5000 \
|
||||
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/prod-smoke.log
|
||||
SMOKE_EXIT=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 生产冒烟测试报告 =========="
|
||||
echo "环境: https://api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
# 提取通过/失败数
|
||||
grep "测试完成:" /tmp/prod-smoke.log || true
|
||||
if [ "$SMOKE_EXIT" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
grep "失败用例:" /tmp/prod-smoke.log || true
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "======================================"
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: saas
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
bash scripts/ci_checkout.sh
|
||||
- name: Run API smoke test on staging
|
||||
id: smoke
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
chmod +x tests/e2e/api_smoke_test.sh
|
||||
docker run --rm \
|
||||
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
||||
-e TEST_USER=18314979086@163.com \
|
||||
-e TEST_PASSWORD=Ying1234 \
|
||||
-e CLEANUP_ENABLED=1 \
|
||||
-e PERF_CHECK_ENABLED=1 \
|
||||
-e PERF_WARN_THRESHOLD_MS=500 \
|
||||
-e PERF_FAIL_THRESHOLD_MS=3000 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash tests/e2e/api_smoke_test.sh 2>&1 | tee /tmp/staging-api-smoke.log
|
||||
SMOKE_EXIT=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging API 冒烟测试报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep "测试完成:" /tmp/staging-api-smoke.log || true
|
||||
if [ "$SMOKE_EXIT" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "api_report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
grep "失败用例:" /tmp/staging-api-smoke.log || true
|
||||
echo "api_report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "=============================================="
|
||||
exit $SMOKE_EXIT
|
||||
|
||||
- name: Run Staging API Integration Tests (Playwright)
|
||||
id: e2e_api
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts" 2>&1 | tee /tmp/staging-api-e2e.log
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging API 集成测试报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep -E "passed|failed|timed out" /tmp/staging-api-e2e.log || true
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "int_report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
echo "int_report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "=============================================="
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Set report output
|
||||
id: report
|
||||
shell: sh
|
||||
run: |
|
||||
if [ "${{ steps.smoke.outputs.api_report }}" = "PASS" ] && [ "${{ steps.e2e_api.outputs.int_report }}" = "PASS" ]; then
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||
staging-e2e:
|
||||
name: Staging Browser E2E
|
||||
runs-on: saas
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
bash scripts/ci_checkout.sh
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('apps/web/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Run Playwright E2E on staging
|
||||
id: e2e
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
docker run --rm --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-v "$HOME/.npm:/root/.npm" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts' 2>&1 | tee /tmp/staging-e2e.log
|
||||
EXIT_CODE=${PIPESTATUS[0]}
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== Staging E2E 测试报告 =========="
|
||||
echo "环境: https://staging.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
grep -E "passed|failed|timed out" /tmp/staging-e2e.log || true
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
echo "结果: PASS"
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "结果: FAIL"
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "=========================================="
|
||||
exit $EXIT_CODE
|
||||
|
||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||
performance-check:
|
||||
name: Performance Baseline Check
|
||||
runs-on: saas
|
||||
timeout-minutes: 8
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Run performance baseline checks
|
||||
id: perf
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
START_TIME=$(date +%s)
|
||||
echo "=========================================="
|
||||
echo " 性能基线巡检 - Staging API"
|
||||
echo " 目标: https://staging-api.xiaoxiajianji.com"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
TOTAL=0
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
WARN_LIST=""
|
||||
FAIL_LIST=""
|
||||
|
||||
# 核心接口配置: 名称|路径|方法|阈值(ms)|失败阈值(ms)
|
||||
# 核心接口(core): 500ms
|
||||
# 普通接口(normal): 1000ms
|
||||
# 重操作接口(heavy): 3000ms
|
||||
ENDPOINTS="
|
||||
登录|/api/v1/auth/login|POST|500|3000
|
||||
获取当前用户|/api/v1/auth/me|GET|500|3000
|
||||
项目列表|/api/v1/projects|GET|500|3000
|
||||
素材列表|/api/v1/assets|GET|500|3000
|
||||
模板列表|/api/v1/templates|GET|500|3000
|
||||
剪辑计划列表|/api/v1/edit-plans|GET|500|3000
|
||||
生成任务列表|/api/v1/generation/tasks|GET|500|3000
|
||||
订阅信息|/api/v1/subscription/current|GET|500|3000
|
||||
音色列表|/api/v1/voices|GET|1000|5000
|
||||
健康检查|/health|GET|200|1000
|
||||
"
|
||||
|
||||
# 先登录获取 token
|
||||
echo "--- 准备: 获取测试 Token ---"
|
||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
|
||||
|
||||
if [ "$AUTH_CODE" = "200" ]; then
|
||||
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null)
|
||||
if [ -n "$TOKEN" ]; then
|
||||
echo "Token 获取成功"
|
||||
else
|
||||
echo "Token 解析失败,部分接口可能无法测试"
|
||||
TOKEN=""
|
||||
fi
|
||||
else
|
||||
echo "登录失败 (HTTP $AUTH_CODE),部分接口将跳过鉴权测试"
|
||||
TOKEN=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- 开始性能测试 ---"
|
||||
echo ""
|
||||
|
||||
echo "$ENDPOINTS" | while IFS='|' read -r name path method warn_ms fail_ms; do
|
||||
[ -z "$name" ] && continue
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
# 构建 curl 命令
|
||||
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||
if [ "$method" = "POST" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
fi
|
||||
|
||||
# 执行请求
|
||||
RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
|
||||
HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
|
||||
TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
|
||||
ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$HTTP_CODE" -ge 500 ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_LIST="$FAIL_LIST\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
|
||||
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms (FAIL)"
|
||||
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
FAIL_LIST="$FAIL_LIST\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms"
|
||||
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms (FAIL)"
|
||||
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
|
||||
WARN=$((WARN + 1))
|
||||
WARN_LIST="$WARN_LIST\n ⚠️ $name - ${ELAPSED_MS}ms > ${warn_ms}ms"
|
||||
echo "⚠️ $name - ${ELAPSED_MS}ms (WARN, threshold: ${warn_ms}ms)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
echo "✅ $name - ${ELAPSED_MS}ms (OK, threshold: ${warn_ms}ms)"
|
||||
fi
|
||||
done
|
||||
|
||||
# 由于 while 在子 shell 中执行,用文件传递结果
|
||||
# 重新跑一次用文件计数方式
|
||||
echo ""
|
||||
echo "--- 汇总性能数据 ---"
|
||||
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 性能基线巡检报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
echo "======================================"
|
||||
|
||||
- name: Generate performance report
|
||||
id: report
|
||||
shell: sh
|
||||
run: |
|
||||
set +e
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 性能基线巡检 - 详细报告"
|
||||
echo "=========================================="
|
||||
|
||||
TOTAL=0
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
RESULTS=""
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
# 先登录获取 token
|
||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||
--max-time 10 2>&1)
|
||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||
AUTH_BODY=$(echo "$AUTH_RESP" | sed '$d')
|
||||
TOKEN=""
|
||||
if [ "$AUTH_CODE" = "200" ]; then
|
||||
TOKEN=$(echo "$AUTH_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
run_perf_test() {
|
||||
local name="$1" path="$2" method="$3" warn_ms="$4" fail_ms="$5"
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||
if [ "$method" = "POST" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||
fi
|
||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||
fi
|
||||
|
||||
local RESP=$(eval curl $CURL_ARGS "https://staging-api.xiaoxiajianji.com${path}" 2>&1)
|
||||
local HTTP_CODE=$(echo "$RESP" | awk '{print $1}')
|
||||
local TIME_TOTAL=$(echo "$RESP" | awk '{print $2}')
|
||||
local ELAPSED_MS=$(python3 -c "print(int(float('${TIME_TOTAL:-0}') * 1000))" 2>/dev/null || echo "0")
|
||||
|
||||
if echo "$HTTP_CODE" | grep -q "^[5]"; then
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS="$RESULTS\n ❌ $name - HTTP $HTTP_CODE (${ELAPSED_MS}ms)"
|
||||
echo "❌ $name - HTTP $HTTP_CODE - ${ELAPSED_MS}ms [FAIL]"
|
||||
return 1
|
||||
elif [ "$ELAPSED_MS" -ge "$fail_ms" ] 2>/dev/null; then
|
||||
FAIL=$((FAIL + 1))
|
||||
RESULTS="$RESULTS\n ❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
|
||||
echo "❌ $name - ${ELAPSED_MS}ms > ${fail_ms}ms [FAIL]"
|
||||
return 1
|
||||
elif [ "$ELAPSED_MS" -ge "$warn_ms" ] 2>/dev/null; then
|
||||
WARN=$((WARN + 1))
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS="$RESULTS\n ⚠️ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [WARN]"
|
||||
echo "⚠️ $name - ${ELAPSED_MS}ms > 阈值 ${warn_ms}ms [WARN]"
|
||||
return 0
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
RESULTS="$RESULTS\n ✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
|
||||
echo "✅ $name - ${ELAPSED_MS}ms (阈值: ${warn_ms}ms) [OK]"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== 核心接口 (阈值: 500ms / 3000ms) ==="
|
||||
run_perf_test "登录" "/api/v1/auth/login" "POST" 500 3000 || true
|
||||
run_perf_test "获取当前用户" "/api/v1/auth/me" "GET" 500 3000 || true
|
||||
run_perf_test "项目列表" "/api/v1/projects" "GET" 500 3000 || true
|
||||
run_perf_test "素材列表" "/api/v1/assets" "GET" 500 3000 || true
|
||||
run_perf_test "模板列表" "/api/v1/templates" "GET" 500 3000 || true
|
||||
run_perf_test "剪辑计划列表" "/api/v1/edit-plans" "GET" 500 3000 || true
|
||||
run_perf_test "生成任务列表" "/api/v1/generation/tasks" "GET" 500 3000 || true
|
||||
run_perf_test "订阅信息" "/api/v1/subscription/current" "GET" 500 3000 || true
|
||||
|
||||
echo ""
|
||||
echo "=== 普通接口 (阈值: 1000ms / 5000ms) ==="
|
||||
run_perf_test "音色列表" "/api/v1/voices" "GET" 1000 5000 || true
|
||||
|
||||
echo ""
|
||||
echo "=== 基础接口 (阈值: 200ms / 1000ms) ==="
|
||||
run_perf_test "健康检查" "/health" "GET" 200 1000 || true
|
||||
|
||||
END_TIME=$(date +%s)
|
||||
ELAPSED=$((END_TIME - START_TIME))
|
||||
|
||||
echo ""
|
||||
echo "========== 性能基线巡检报告 =========="
|
||||
echo "环境: https://staging-api.xiaoxiajianji.com"
|
||||
echo "总接口: ${TOTAL}"
|
||||
echo "通过: ${PASS}"
|
||||
echo "失败: ${FAIL}"
|
||||
echo "警告: ${WARN}"
|
||||
echo "耗时: ${ELAPSED}s"
|
||||
echo "======================================"
|
||||
|
||||
# 写入结果文件供 report job 使用
|
||||
echo "${TOTAL}" > /tmp/perf_total
|
||||
echo "${PASS}" > /tmp/perf_pass
|
||||
echo "${FAIL}" > /tmp/perf_fail
|
||||
echo "${WARN}" > /tmp/perf_warn
|
||||
echo "${ELAPSED}" > /tmp/perf_elapsed
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||
echo "perf_detail=fail:${FAIL}:warn:${WARN}" >> "${GITHUB_OUTPUT}"
|
||||
exit 1
|
||||
else
|
||||
echo "report=PASS" >> "${GITHUB_OUTPUT}"
|
||||
if [ "$WARN" -gt 0 ]; then
|
||||
echo "perf_detail=pass:warn:${WARN}" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "perf_detail=pass" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||
daily-report:
|
||||
name: Daily Check Report
|
||||
runs-on: saas
|
||||
timeout-minutes: 2
|
||||
if: always()
|
||||
needs:
|
||||
- production-smoke
|
||||
- staging-api-tests
|
||||
- staging-e2e
|
||||
- performance-check
|
||||
|
||||
steps:
|
||||
- name: Print summary report
|
||||
shell: sh
|
||||
run: |
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ 每日巡检报告 ║"
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
|
||||
# 获取各 job 状态
|
||||
PROD_STATUS="${{ needs.production-smoke.result }}"
|
||||
STAGING_API_STATUS="${{ needs.staging-api-tests.result }}"
|
||||
STAGING_E2E_STATUS="${{ needs.staging-e2e.result }}"
|
||||
PERF_STATUS="${{ needs.performance-check.result }}"
|
||||
|
||||
format_result() {
|
||||
if [ "$1" = "success" ]; then
|
||||
echo "✅ PASS"
|
||||
elif [ "$1" = "failure" ]; then
|
||||
echo "❌ FAIL"
|
||||
elif [ "$1" = "skipped" ]; then
|
||||
echo "⏭️ SKIP"
|
||||
else
|
||||
echo "❓ UNKNOWN ($1)"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "║"
|
||||
echo "║ 生产冒烟测试: $(format_result "$PROD_STATUS")"
|
||||
echo "║ Staging API: $(format_result "$STAGING_API_STATUS")"
|
||||
echo "║ Staging E2E: $(format_result "$STAGING_E2E_STATUS")"
|
||||
echo "║ 性能基线巡检: $(format_result "$PERF_STATUS")"
|
||||
echo "║"
|
||||
echo "║ 巡检时间: $(date '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "║"
|
||||
|
||||
# 判断整体状态
|
||||
ALL_PASS=true
|
||||
FAILED_ITEMS=""
|
||||
for status_name in "$PROD_STATUS:生产冒烟" "$STAGING_API_STATUS:Staging API" "$STAGING_E2E_STATUS:Staging E2E" "$PERF_STATUS:性能基线"; do
|
||||
STATUS=$(echo "$status_name" | cut -d: -f1)
|
||||
NAME=$(echo "$status_name" | cut -d: -f2)
|
||||
if [ "$STATUS" != "success" ] && [ "$STATUS" != "skipped" ]; then
|
||||
ALL_PASS=false
|
||||
FAILED_ITEMS="$FAILED_ITEMS $NAME"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "╠══════════════════════════════════════════════════════╣"
|
||||
if [ "$ALL_PASS" = "true" ]; then
|
||||
echo "║ 整体状态: ✅ 全部通过 ║"
|
||||
else
|
||||
echo "║ 整体状态: ❌ 存在失败 ║"
|
||||
echo "║ 失败项: ${FAILED_ITEMS} ║"
|
||||
fi
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 如果有失败项,以非零退出码结束(方便 Gitea 标记流水线失败)
|
||||
if [ "$ALL_PASS" = "false" ]; then
|
||||
echo "⚠️ 部分巡检项失败,请检查上方日志获取详细信息。"
|
||||
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
|
||||
# 但其他失败的 job 已经让整体流水线标记为失败
|
||||
fi
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Test SSH Secret
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- '.gitea/workflows/test-ssh-secret.yml'
|
||||
|
||||
jobs:
|
||||
test-ssh:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
run: |
|
||||
which ssh || (apt-get update && apt-get install -y openssh-client)
|
||||
ssh -V
|
||||
|
||||
- name: Debug environment
|
||||
run: |
|
||||
echo "=== Environment ==="
|
||||
echo "Runner hostname: $(hostname)"
|
||||
echo "Runner IP: $(hostname -i || echo 'unknown')"
|
||||
echo "Current user: $(whoami)"
|
||||
echo "=== Secrets check ==="
|
||||
if [ -n "$STAGING_SSH_HOST" ]; then
|
||||
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
|
||||
else
|
||||
echo "STAGING_SSH_HOST: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_USER" ]; then
|
||||
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
|
||||
else
|
||||
echo "STAGING_SSH_USER: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_KEY" ]; then
|
||||
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
|
||||
else
|
||||
echo "STAGING_SSH_KEY: [EMPTY]"
|
||||
fi
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
|
||||
echo "=== SSH Key fingerprint ==="
|
||||
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=10 \
|
||||
-o BatchMode=yes \
|
||||
-v \
|
||||
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
|
||||
echo "=== SSH Test Complete ==="
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
@@ -11,9 +11,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
@@ -93,9 +95,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""CMS Enhancements (placeholder - manually applied on production)
|
||||
|
||||
Revision ID: 034_cms_enhance
|
||||
Revises: 033
|
||||
Create Date: 2026-07-09
|
||||
|
||||
占位迁移文件:生产数据库已手动升级到此版本,
|
||||
此文件用于让 alembic 识别当前版本,避免部署时迁移失败。
|
||||
实际的表结构变更(helpcenter, tickets, partners, site_settings 等)
|
||||
已在生产环境手动执行。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "034_cms_enhance"
|
||||
down_revision = "033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""占位 - 变更已在生产环境手动应用"""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""占位 - 不执行实际回退"""
|
||||
pass
|
||||
@@ -1,11 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.dependencies import get_asset_library_repository, get_project_repository
|
||||
from app.schemas.asset_library import (
|
||||
AssetLibraryResponse,
|
||||
CreateAssetLibraryRequest,
|
||||
@@ -151,30 +147,3 @@ def ensure_default_library(
|
||||
)
|
||||
created = asset_library_repository.create(library)
|
||||
return _to_asset_library_response(created)
|
||||
|
||||
|
||||
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_asset_library(
|
||||
library_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除素材库,同时删除库内所有素材。"""
|
||||
# 查找素材库
|
||||
library = asset_library_repository.find_by_id(library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材库不存在")
|
||||
|
||||
# 权限校验:检查用户是否有项目访问权限
|
||||
_check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 删除库内所有素材(无 FK 级联,需手动清理)
|
||||
assets_in_library = asset_repository.find_by_library(library_id)
|
||||
if assets_in_library:
|
||||
asset_ids_to_delete = [a.id for a in assets_in_library]
|
||||
asset_repository.batch_delete(asset_ids_to_delete)
|
||||
|
||||
# 删除素材库本身
|
||||
asset_library_repository.delete(library_id)
|
||||
|
||||
@@ -274,6 +274,79 @@ async def init_chunked_upload(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{upload_id}/{chunk_index}")
|
||||
async def upload_chunk(
|
||||
upload_id: str,
|
||||
chunk_index: int,
|
||||
chunk: UploadFile = File(..., description="Chunk data"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Upload a single chunk"""
|
||||
# Load metadata
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Check expiry
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
|
||||
|
||||
# Validate chunk index
|
||||
if chunk_index < 0 or chunk_index >= meta["total_chunks"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
|
||||
)
|
||||
|
||||
# Atomic check and record to prevent race conditions
|
||||
if not _atomic_check_and_record(upload_id, chunk_index):
|
||||
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
|
||||
|
||||
# Read chunk data
|
||||
chunk_data = await chunk.read()
|
||||
|
||||
# Validate chunk size (last chunk can be smaller than chunk_size)
|
||||
expected_size = DEFAULT_CHUNK_SIZE
|
||||
if chunk_index == meta["total_chunks"] - 1:
|
||||
expected_size = meta["file_size"] - (chunk_index * DEFAULT_CHUNK_SIZE)
|
||||
|
||||
if len(chunk_data) != expected_size:
|
||||
# Rollback the recorded chunk
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
with open(meta_path, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
meta = json.load(f)
|
||||
if chunk_index in meta["uploaded_chunks"]:
|
||||
meta["uploaded_chunks"].remove(chunk_index)
|
||||
f.seek(0)
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
f.truncate()
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Chunk size mismatch. Expected {expected_size}, got {len(chunk_data)}",
|
||||
)
|
||||
|
||||
# Save chunk
|
||||
chunk_path = _get_chunk_dir(upload_id) / f"chunk_{chunk_index:06d}"
|
||||
with open(chunk_path, "wb") as f:
|
||||
f.write(chunk_data)
|
||||
|
||||
# Reload metadata for response
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
return {
|
||||
"message": "Chunk uploaded successfully",
|
||||
"chunk_index": chunk_index,
|
||||
"uploaded_chunks": len(meta["uploaded_chunks"]),
|
||||
"total_chunks": meta["total_chunks"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{upload_id}/status", response_model=ChunkedUploadStatusResponse)
|
||||
async def get_upload_status(
|
||||
upload_id: str,
|
||||
@@ -420,76 +493,3 @@ async def complete_chunked_upload(
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
|
||||
|
||||
@router.post("/{upload_id}/{chunk_index}")
|
||||
async def upload_chunk(
|
||||
upload_id: str,
|
||||
chunk_index: int,
|
||||
chunk: UploadFile = File(..., description="Chunk data"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Upload a single chunk"""
|
||||
# Load metadata
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
# Check expiry
|
||||
expires_at = datetime.fromisoformat(meta["expires_at"])
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired")
|
||||
|
||||
# Validate chunk index
|
||||
if chunk_index < 0 or chunk_index >= meta["total_chunks"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
|
||||
)
|
||||
|
||||
# Atomic check and record to prevent race conditions
|
||||
if not _atomic_check_and_record(upload_id, chunk_index):
|
||||
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
|
||||
|
||||
# Read chunk data
|
||||
chunk_data = await chunk.read()
|
||||
|
||||
# Validate chunk size (last chunk can be smaller than chunk_size)
|
||||
expected_size = DEFAULT_CHUNK_SIZE
|
||||
if chunk_index == meta["total_chunks"] - 1:
|
||||
expected_size = meta["file_size"] - (chunk_index * DEFAULT_CHUNK_SIZE)
|
||||
|
||||
if len(chunk_data) != expected_size:
|
||||
# Rollback the recorded chunk
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
with open(meta_path, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
meta = json.load(f)
|
||||
if chunk_index in meta["uploaded_chunks"]:
|
||||
meta["uploaded_chunks"].remove(chunk_index)
|
||||
f.seek(0)
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
f.truncate()
|
||||
finally:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Chunk size mismatch. Expected {expected_size}, got {len(chunk_data)}",
|
||||
)
|
||||
|
||||
# Save chunk
|
||||
chunk_path = _get_chunk_dir(upload_id) / f"chunk_{chunk_index:06d}"
|
||||
with open(chunk_path, "wb") as f:
|
||||
f.write(chunk_data)
|
||||
|
||||
# Reload metadata for response
|
||||
meta = _load_upload_meta(upload_id)
|
||||
|
||||
return {
|
||||
"message": "Chunk uploaded successfully",
|
||||
"chunk_index": chunk_index,
|
||||
"uploaded_chunks": len(meta["uploaded_chunks"]),
|
||||
"total_chunks": meta["total_chunks"],
|
||||
}
|
||||
|
||||
@@ -22,28 +22,16 @@ from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
|
||||
SQLAlchemyAssetLibraryRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.application.generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
@@ -212,9 +200,9 @@ def _check_project_access(project_id: str, user_id: str, project_repository: Any
|
||||
return
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(user_id):
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
def _to_response(p: EditPlan) -> EditPlanResponse:
|
||||
@@ -265,7 +253,7 @@ def list_plans(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的筛选条件,请选择正确的状态",
|
||||
detail=(f"无效的状态值: {status_filter}," f"可选值: draft, editing, rendering, completed, failed"),
|
||||
)
|
||||
|
||||
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
|
||||
@@ -391,7 +379,7 @@ def update_plan(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的状态值,请选择正确的状态",
|
||||
detail=(f"无效的状态值: {body.status}," f"可选值: draft, editing, rendering, completed, failed"),
|
||||
)
|
||||
svc.transition_status(plan_id, target_status)
|
||||
except ValueError as exc:
|
||||
@@ -447,8 +435,6 @@ def generate_plan(
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||
asset_repo: Any = Depends(get_asset_repository),
|
||||
) -> EditPlanGenerateResponse:
|
||||
"""触发剪辑计划渲染生成
|
||||
|
||||
@@ -468,121 +454,6 @@ def generate_plan(
|
||||
if plan_check.project_id:
|
||||
_check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# ── 自动兜底 1: draft → editing ──────────────────────────────────────
|
||||
if plan_check.status == EditPlanStatus.DRAFT:
|
||||
logger.info("自动兜底: plan=%s draft→editing", plan_id)
|
||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||
|
||||
# ── 自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置 ──────────
|
||||
existing_clips = svc.count_clips(plan_id)
|
||||
if existing_clips == 0 and plan_check.template_id:
|
||||
logger.info(
|
||||
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||
plan_id,
|
||||
plan_check.template_id,
|
||||
)
|
||||
# 优先从新模型 template_clip_configs 读取,若无则回退到旧模型 template_segments
|
||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||
if configs:
|
||||
for cfg in configs:
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||
order=cfg.order,
|
||||
template_clip_config_id=cfg.id,
|
||||
duration=cfg.default_duration,
|
||||
transition_effect=(
|
||||
cfg.transition_effect.value
|
||||
if hasattr(cfg.transition_effect, "value")
|
||||
else cfg.transition_effect
|
||||
),
|
||||
)
|
||||
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
|
||||
else:
|
||||
# 回退到旧模型 template_segments
|
||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||
for seg in segments:
|
||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||
svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type="main", # 旧模型无结构角色,统一为主体片段
|
||||
order=seg.segment_order,
|
||||
duration=avg_duration,
|
||||
config={
|
||||
"material_type": seg.material_type or "",
|
||||
"template_segment_id": seg.id,
|
||||
},
|
||||
)
|
||||
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
|
||||
|
||||
# ── 自动兜底 3: 为没有素材的片段分配素材 ──────────────────────────
|
||||
# 如果 plan.config.asset_ids 有素材,但 clips 没有 asset_id,自动按顺序分配
|
||||
all_clips = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
material_mode = (plan_check.config or {}).get("material_mode", "manual")
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||
clips_without_asset = [] # 已分配完
|
||||
|
||||
# ── 自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取 ────────────
|
||||
if clips_without_asset and material_mode == "auto" and plan_check.project_id:
|
||||
import random
|
||||
|
||||
logger.info(
|
||||
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 找到项目的视频素材库
|
||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||
video_lib = None
|
||||
for lib in libs:
|
||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if lib_kind == "video":
|
||||
video_lib = lib
|
||||
break
|
||||
|
||||
if video_lib:
|
||||
assets = asset_repo.find_by_library(video_lib.id)
|
||||
# 筛选 ready 状态的视频素材
|
||||
ready_videos = [
|
||||
a
|
||||
for a in assets
|
||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||
and a.mime_type
|
||||
and a.mime_type.startswith("video")
|
||||
]
|
||||
if ready_videos:
|
||||
# 随机选取,按片段数轮询分配
|
||||
random.shuffle(ready_videos)
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset = ready_videos[i % len(ready_videos)]
|
||||
svc.assign_asset(clip.id, asset.id)
|
||||
logger.info(
|
||||
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
|
||||
plan_id,
|
||||
video_lib.name,
|
||||
len(ready_videos),
|
||||
len(clips_without_asset),
|
||||
)
|
||||
else:
|
||||
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
|
||||
else:
|
||||
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
|
||||
|
||||
# 检查是否可生成
|
||||
try:
|
||||
can_gen, reason = svc.can_generate(plan_id)
|
||||
@@ -597,64 +468,48 @@ def generate_plan(
|
||||
detail=reason,
|
||||
)
|
||||
|
||||
# 核心生成流程:捕获异常返回明确错误信息,避免裸 500
|
||||
try:
|
||||
# 将 pending 片段标记为 ready
|
||||
clip_count = svc.mark_clips_ready(plan_id)
|
||||
# 将 pending 片段标记为 ready
|
||||
clip_count = svc.mark_clips_ready(plan_id)
|
||||
|
||||
# 创建 GenerationTask
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
)
|
||||
# 创建 GenerationTask
|
||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||
plan = svc.get_plan_or_raise(plan_id)
|
||||
gen_task = gen_task_use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
template_id=plan.template_id,
|
||||
created_by_user_id=current_user.user.id,
|
||||
source_edit_plan_id=plan_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 将 generation_task_id 存入 plan config
|
||||
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
# 将 generation_task_id 存入 plan config
|
||||
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||
|
||||
# 流转状态为 rendering
|
||||
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
# 流转状态为 rendering
|
||||
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||
|
||||
# 调度 Celery 任务
|
||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||
# 调度 Celery 任务
|
||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||
|
||||
# 获取最新状态
|
||||
updated_plan = svc.get_plan_or_raise(plan_id)
|
||||
# 获取最新状态
|
||||
updated_plan = svc.get_plan_or_raise(plan_id)
|
||||
|
||||
logger.info(
|
||||
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
logger.info(
|
||||
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||
plan_id,
|
||||
gen_task.id,
|
||||
clip_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
except HTTPException:
|
||||
# 已处理的 HTTP 异常直接透传
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||||
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
|
||||
try:
|
||||
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||
except Exception:
|
||||
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="生成失败,请稍后重试",
|
||||
)
|
||||
return EditPlanGenerateResponse(
|
||||
plan_id=plan_id,
|
||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||
generation_task_id=gen_task.id,
|
||||
clip_count=clip_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -801,7 +656,7 @@ def ai_recommend_clips(
|
||||
if plan_status not in ("draft", "editing"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
|
||||
detail=f"AI 推荐仅支持 draft/editing 状态的计划,当前状态: {plan_status}",
|
||||
)
|
||||
|
||||
# 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步)
|
||||
@@ -852,7 +707,7 @@ def ai_recommend_clips(
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="AI推荐结果保存失败,请稍后重试",
|
||||
detail=f"AI 推荐结果写入失败: {exc}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -447,12 +447,12 @@ class EditPlanService:
|
||||
|
||||
# 检查状态
|
||||
if plan.status != EditPlanStatus.EDITING:
|
||||
return False, "请先编辑并保存模板后再生成视频"
|
||||
return False, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}"
|
||||
|
||||
# 检查是否有片段
|
||||
clips = self._clip_repo.list_by_plan(plan_id)
|
||||
if not clips:
|
||||
return False, "请先添加片段后再生成视频"
|
||||
return False, "计划下没有片段,无法触发渲染"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
@@ -1,626 +0,0 @@
|
||||
/**
|
||||
* 素材库页面完整 E2E 测试
|
||||
*
|
||||
* 覆盖:页面加载、创建素材库、切换素材库、搜索/筛选、素材详情、
|
||||
* 删除素材、批量删除、空状态
|
||||
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建项目 */
|
||||
async function createProject(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
|
||||
});
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** 创建素材库 */
|
||||
async function createLibrary(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
projectId: string,
|
||||
name: string,
|
||||
kind: "video" | "image" = "video",
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectId, name, kind },
|
||||
});
|
||||
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** 创建素材记录 */
|
||||
async function createAsset(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
projectId: string,
|
||||
libraryId: string,
|
||||
userId: string,
|
||||
name: string,
|
||||
status: string = "ready",
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/assets`, {
|
||||
headers,
|
||||
data: {
|
||||
project_id: projectId,
|
||||
library_id: libraryId,
|
||||
name,
|
||||
storage_key: `uploads/e2e/${Date.now()}/${name}`,
|
||||
mime_type: "video/mp4",
|
||||
file_size: 1024000,
|
||||
status,
|
||||
uploaded_by_user_id: userId,
|
||||
metadata: { duration: 15.5, resolution: "1080p" },
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
async function setupAuthInBrowser(
|
||||
page: import("@playwright/test").Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("素材库页面 - 完整交互测试", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 页面加载 ──────────────────────────────────────
|
||||
|
||||
test("素材库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-load",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
|
||||
// 页面布局容器
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 左侧素材库列表
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||
|
||||
// 右侧内容区(上传区 + 筛选 + 素材网格)
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible();
|
||||
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
|
||||
await expect(page.locator(".xx-assets-filters")).toBeVisible();
|
||||
|
||||
// 无错误提示
|
||||
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 创建素材库 ────────────────────────────────────
|
||||
|
||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-create",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 点击新建素材库
|
||||
await page.locator(".xx-asset-library-add").click();
|
||||
|
||||
// 弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 填写表单
|
||||
const newLibName = `E2E 新建库 ${Date.now()}`;
|
||||
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
|
||||
// 类型选择默认是 video,保持即可
|
||||
|
||||
// 监听创建请求
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/asset-libraries") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
// 点击创建
|
||||
await modal.getByRole("button", { name: "创建" }).click();
|
||||
|
||||
const resp = await createPromise;
|
||||
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 新素材库应出现在列表中
|
||||
await expect(
|
||||
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
// ─── 切换素材库 ────────────────────────────────────
|
||||
|
||||
test("切换不同素材库", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-switch",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
|
||||
const videoLibName = "视频素材库 A";
|
||||
const imageLibName = "图片素材库 B";
|
||||
const videoLibId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
videoLibName,
|
||||
"video",
|
||||
);
|
||||
const imageLibId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
imageLibName,
|
||||
"image",
|
||||
);
|
||||
|
||||
// 在视频库里创建一个素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
videoLibId,
|
||||
userId,
|
||||
"demo_video.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 点击视频库,应显示素材
|
||||
const videoLibItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: videoLibName });
|
||||
await videoLibItem.click({ force: true });
|
||||
await expect(videoLibItem).toHaveClass(/active/);
|
||||
|
||||
// 验证视频素材出现
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 点击图片库,应切换且不显示视频
|
||||
const imageLibItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: imageLibName });
|
||||
await imageLibItem.click({ force: true });
|
||||
await expect(imageLibItem).toHaveClass(/active/);
|
||||
|
||||
// 空状态或图片库内容
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ─── 素材搜索 ──────────────────────────────────────
|
||||
|
||||
test("素材搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-search",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"搜索测试库",
|
||||
"video",
|
||||
);
|
||||
|
||||
// 创建两个不同名称的素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 确保在测试库中
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "搜索测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 两个素材都应可见
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
|
||||
// 搜索 apple,只显示 apple
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("apple");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible();
|
||||
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0);
|
||||
|
||||
// 清空搜索,两个都显示
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 筛选类型 ──────────────────────────────────────
|
||||
|
||||
test("素材类型筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-filter",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"筛选测试库",
|
||||
"video",
|
||||
);
|
||||
|
||||
// 创建视频素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "筛选测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 素材应可见
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 筛选类型下拉存在
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||
await expect(filterSelect).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 素材详情/播放 ────────────────────────────────
|
||||
|
||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-detail",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"详情测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "详情测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 找到素材卡片并点击播放按钮
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "play_test.mp4" });
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 点击播放按钮
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||
|
||||
// 播放弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 关闭弹窗
|
||||
await modal.locator(".ant-modal-close").click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// ─── 删除素材 ──────────────────────────────────────
|
||||
|
||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-delete",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"删除测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "删除测试库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "to_delete.mp4" });
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 悬停显示删除按钮
|
||||
await assetCard.hover();
|
||||
|
||||
// 点击删除
|
||||
const deleteBtn = assetCard.locator(".xx-asset-delete");
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
await deleteBtn.click({ force: true });
|
||||
|
||||
// 确认对话框出现
|
||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
|
||||
await expect(confirmModal).toBeVisible();
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/assets/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
// 点击确认删除
|
||||
await confirmModal.getByRole("button", { name: "删除" }).click();
|
||||
|
||||
const resp = await deletePromise;
|
||||
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 素材应从列表中消失
|
||||
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 批量删除素材 ──────────────────────────────────
|
||||
|
||||
test("批量删除素材", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-batch",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"批量删除库",
|
||||
"video",
|
||||
);
|
||||
|
||||
// 创建多个素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "批量删除库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 所有素材应可见
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||
|
||||
// 点击全选
|
||||
const selectAllBtn = page.getByRole("button", { name: "全选" });
|
||||
await expect(selectAllBtn).toBeVisible();
|
||||
await selectAllBtn.click();
|
||||
|
||||
// 批量操作栏出现
|
||||
const batchBar = page.locator(".xx-assets-batch-bar");
|
||||
await expect(batchBar).toBeVisible();
|
||||
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
|
||||
|
||||
// 点击批量删除
|
||||
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
|
||||
await expect(batchDeleteBtn).toBeVisible();
|
||||
await batchDeleteBtn.click();
|
||||
|
||||
// 确认对话框
|
||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
|
||||
await expect(confirmPop).toBeVisible();
|
||||
|
||||
// 确认删除
|
||||
await confirmPop.getByRole("button", { name: "删除" }).click();
|
||||
|
||||
// 验证素材已删除(通过 API 确认)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const resp = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryId },
|
||||
});
|
||||
if (!resp.ok()) return "error";
|
||||
const data = await resp.json();
|
||||
const items = data.items || [];
|
||||
return items.length;
|
||||
},
|
||||
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
// ─── 空状态 ────────────────────────────────────────
|
||||
|
||||
test("空素材库展示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"assets-empty",
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "空素材库" });
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/assets");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
@@ -1,554 +0,0 @@
|
||||
/**
|
||||
* 去重流程 E2E 测试
|
||||
*
|
||||
* 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、
|
||||
* 删除记录、重试去重
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
async function setupAuthInBrowser(
|
||||
page: import("@playwright/test").Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("去重流程", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 上传页面加载 ──────────────────────────────────
|
||||
|
||||
test("去重上传页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-load",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible();
|
||||
|
||||
// 描述
|
||||
await expect(
|
||||
page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 上传区域展示 ──────────────────────────────────
|
||||
|
||||
test("上传区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-upload-zone",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 拖拽上传区域
|
||||
const uploadZone = page.locator(".dup-upload-zone");
|
||||
await expect(uploadZone).toBeVisible();
|
||||
|
||||
// 上传图标和文字
|
||||
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
|
||||
|
||||
// 格式提示
|
||||
await expect(
|
||||
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
|
||||
).toBeVisible();
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-upload-formats")).toBeVisible();
|
||||
|
||||
// 选择文件按钮
|
||||
const selectBtn = page.getByRole("button", { name: "选择文件" });
|
||||
await expect(selectBtn).toBeVisible();
|
||||
|
||||
// 隐藏的文件 input
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await expect(fileInput).toHaveCount(1);
|
||||
});
|
||||
|
||||
// ─── 格式说明区 ────────────────────────────────────
|
||||
|
||||
test("格式说明和提示区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-info",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 右侧说明区
|
||||
const infoCard = page.locator(".dup-info-card");
|
||||
await expect(infoCard).toBeVisible();
|
||||
|
||||
// 查重说明
|
||||
await expect(infoCard.getByText("查重说明")).toBeVisible();
|
||||
|
||||
// 支持格式
|
||||
await expect(infoCard.getByText("支持格式")).toBeVisible();
|
||||
|
||||
// 温馨提示
|
||||
await expect(infoCard.getByText("温馨提示")).toBeVisible();
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-format-tags")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 去重记录列表页面 ──────────────────────────────
|
||||
|
||||
test("去重记录列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-list",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible();
|
||||
|
||||
// 筛选按钮
|
||||
await expect(page.locator(".dup-filter")).toBeVisible();
|
||||
|
||||
// 上传查重按钮
|
||||
await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("去重记录列表 - 空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-list-empty",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 空状态(新用户没有记录)
|
||||
const emptyState = page.locator(".dup-results-empty");
|
||||
await expect(emptyState).toBeVisible({ timeout: 10_000 });
|
||||
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("去重记录列表 - 风险等级筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-filter",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 筛选按钮存在
|
||||
const filterBtns = page.locator(".dup-filter-btn");
|
||||
await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险
|
||||
|
||||
// 验证按钮文本
|
||||
await expect(filterBtns.nth(0)).toHaveText("全部");
|
||||
await expect(filterBtns.nth(1)).toHaveText("低风险");
|
||||
await expect(filterBtns.nth(2)).toHaveText("中风险");
|
||||
await expect(filterBtns.nth(3)).toHaveText("高风险");
|
||||
|
||||
// 默认选中"全部"
|
||||
await expect(filterBtns.nth(0)).toHaveClass(/active/);
|
||||
|
||||
// 点击低风险
|
||||
await filterBtns.nth(1).click();
|
||||
await expect(filterBtns.nth(1)).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-nav",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 点击上传查重按钮
|
||||
await page.getByRole("button", { name: "上传查重" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/app\/duplication$/);
|
||||
await expect(page.locator(".dup-upload-zone")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 去重详情页 ────────────────────────────────────
|
||||
|
||||
test("去重详情页 - 通过 API 创建测试数据后访问", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-detail",
|
||||
);
|
||||
|
||||
// 先上传一个文件进行查重,获取 record id
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e_dup_test.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e duplication test data"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 如果查重 API 不可用,跳过详情页测试
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
// 访问详情页
|
||||
await page.goto(`/app/duplication/${recordId}`);
|
||||
|
||||
// 页面应正常渲染
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 验证无错误
|
||||
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 删除记录 ──────────────────────────────────────
|
||||
|
||||
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e_dup_delete.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e duplication delete test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
|
||||
// 验证记录存在
|
||||
const listResp = await request.get(`${apiBase}/duplication/records`, {
|
||||
headers,
|
||||
});
|
||||
if (listResp.ok()) {
|
||||
const records = await listResp.json();
|
||||
const recordExists = Array.isArray(records)
|
||||
? records.some((r: { id: string }) => r.id === recordId)
|
||||
: (records.items || []).some((r: { id: string }) => r.id === recordId);
|
||||
expect(recordExists, "记录应存在于列表中").toBeTruthy();
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/duplication/records/${recordId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
deleteResp.ok(),
|
||||
`删除查重记录应成功: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证记录已删除
|
||||
const listAfterResp = await request.get(`${apiBase}/duplication/records`, {
|
||||
headers,
|
||||
});
|
||||
if (listAfterResp.ok()) {
|
||||
const recordsAfter = await listAfterResp.json();
|
||||
const recordStillExists = Array.isArray(recordsAfter)
|
||||
? recordsAfter.some((r: { id: string }) => r.id === recordId)
|
||||
: (recordsAfter.items || []).some(
|
||||
(r: { id: string }) => r.id === recordId,
|
||||
);
|
||||
expect(recordStillExists, "记录应已被删除").toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete-ui",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e_dup_ui_delete.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e duplication ui delete test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录卡片应存在
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 删除按钮存在
|
||||
const deleteBtn = resultCard.getByRole("button").filter({
|
||||
hasText: "🗑️",
|
||||
});
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
|
||||
// 删除按钮点击 - 会触发 confirm 对话框
|
||||
// 这里我们通过监听 confirm 来确认删除
|
||||
page.once("dialog", async (dialog) => {
|
||||
expect(dialog.message()).toContain("确定删除");
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
).catch(() => null);
|
||||
|
||||
await deleteBtn.click();
|
||||
|
||||
const deleteResp = await deletePromise;
|
||||
if (deleteResp) {
|
||||
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 重试去重 ──────────────────────────────────────
|
||||
|
||||
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-retry",
|
||||
);
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e_dup_retry.mp4",
|
||||
mimeType: "video/mp4",
|
||||
buffer: Buffer.from("e2e duplication retry test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录列表中至少有一条记录
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 验证记录卡片基本结构
|
||||
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible();
|
||||
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible();
|
||||
|
||||
// 检查是否有重试按钮(失败状态才显示)
|
||||
// 新上传的记录可能是处理中或完成状态,不一定显示重试按钮
|
||||
// 这里只验证 API 重试接口可用
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
|
||||
const retryResp = await request.post(
|
||||
`${apiBase}/duplication/records/${recordId}/retry`,
|
||||
{ headers },
|
||||
);
|
||||
// 重试接口应返回 2xx 或明确的状态码
|
||||
expect(retryResp.status()).toBeLessThan(500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
@@ -1,480 +0,0 @@
|
||||
/**
|
||||
* 剪辑策划页面 E2E 测试
|
||||
*
|
||||
* 覆盖:页面加载、模板列表、模式切换、创建/编辑/删除剪辑计划、
|
||||
* AI推荐片段、详情页、空状态、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建一个编辑模板并返回 id */
|
||||
async function createEditingTemplate(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 剪辑计划 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "E2E 测试创建的剪辑计划",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
description: "开场片段",
|
||||
},
|
||||
{
|
||||
segment_order: 2,
|
||||
duration_min: 10,
|
||||
duration_max: 20,
|
||||
material_type: "video",
|
||||
description: "主体内容",
|
||||
},
|
||||
],
|
||||
tags: ["e2e", "test"],
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
test.describe("剪辑策划页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑策划页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("剪辑策划页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
// 验证顶栏存在
|
||||
await expect(page.locator(".ep-top-bar")).toBeVisible();
|
||||
// 验证模式栏存在
|
||||
await expect(page.locator(".ep-mode-bar")).toBeVisible();
|
||||
// 验证主体区域存在
|
||||
await expect(page.locator(".ep-main-body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("剪辑模式切换正常显示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-mode",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-mode",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证模式按钮存在(画中画、人物口播等)
|
||||
const modeBtns = page.locator(".ep-mode-btn");
|
||||
await expect(modeBtns.first()).toBeVisible();
|
||||
const modeCount = await modeBtns.count();
|
||||
expect(modeCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑计划 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("创建剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-create");
|
||||
const suffix = Date.now().toString(36);
|
||||
const templateName = `E2E 创建测试 ${suffix}`;
|
||||
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: templateName,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "测试创建剪辑计划",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 10,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回模板 ID").toBeTruthy();
|
||||
expect(data.name).toBe(templateName);
|
||||
expect(data.mode).toBe("pip");
|
||||
});
|
||||
|
||||
test("列出剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-list");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建 2 个模板
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 列表测试 A ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 列表测试 B ${suffix}`,
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
segments: [
|
||||
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出模板应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.templates || [];
|
||||
expect(Array.isArray(items), "返回应为数组").toBeTruthy();
|
||||
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("获取剪辑计划详情 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-detail");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
const response = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取详情应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(templateId);
|
||||
expect(data.name).toBeTruthy();
|
||||
expect(data.mode).toBeTruthy();
|
||||
});
|
||||
|
||||
test("编辑剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-update");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
const newName = `更新后的剪辑计划 ${Date.now()}`;
|
||||
const response = await request.patch(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
data: {
|
||||
name: newName,
|
||||
description: "更新后的描述",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.name).toBe(newName);
|
||||
|
||||
// 验证更新后的数据
|
||||
const verify = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
expect(verifyData.name).toBe(newName);
|
||||
});
|
||||
|
||||
test("删除剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-delete");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/templates/${templateId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
});
|
||||
|
||||
test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-badmode");
|
||||
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: "无效 mode 测试",
|
||||
mode: "invalid_mode",
|
||||
estimated_duration: 30,
|
||||
segments: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("获取不存在的剪辑计划 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-404");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/templates/nonexistent-template-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的模板应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("未登录创建剪辑计划 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
data: {
|
||||
name: "未登录测试",
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [],
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("剪辑策划页面 - 已模板数据加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("已创建的模板在页面中显示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "ep-data");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createEditingTemplate(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-data",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证状态栏存在
|
||||
await expect(page.locator(".ep-status-bar")).toBeVisible();
|
||||
});
|
||||
|
||||
test("撤销/重做按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-undo",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-undo",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证顶栏按钮存在(撤销、重做、保存、生成等)
|
||||
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn");
|
||||
await expect(topBarBtns.first()).toBeVisible();
|
||||
const btnCount = await topBarBtns.count();
|
||||
expect(btnCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("生成按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-gen",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-gen",
|
||||
});
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证主操作按钮存在
|
||||
await expect(page.locator(".ep-btn-primary")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,670 +0,0 @@
|
||||
/**
|
||||
* 作品库页面 E2E 测试
|
||||
*
|
||||
* 覆盖:作品库列表加载、状态展示、作品详情、视频播放、下载按钮、
|
||||
* 删除作品、空状态、筛选
|
||||
*
|
||||
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
|
||||
* 真实的生成流程测试见 core-generation.spec.ts。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock 产品数据 */
|
||||
function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
const products = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const status = statuses[i % statuses.length];
|
||||
products.push({
|
||||
id: `mock-prod-${Date.now()}-${i}`,
|
||||
title: `测试作品 ${i + 1}`,
|
||||
status,
|
||||
duration_seconds: 30 + i * 10,
|
||||
resolution: "1080x1920",
|
||||
file_size: (5 + i) * 1024 * 1024,
|
||||
duplicate_rate: i * 5,
|
||||
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
thumbnail_url: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return products;
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
async function setupAuthInBrowser(
|
||||
page: import("@playwright/test").Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Mock 产品列表 API */
|
||||
async function mockProductsApi(
|
||||
page: import("@playwright/test").Page,
|
||||
products: unknown[],
|
||||
) {
|
||||
await page.route("**/api/v1/products", (route) => {
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
|
||||
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
|
||||
// 列表
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: products, total: products.length }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 单个产品详情
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
|
||||
if (method === "GET" && detailMatch) {
|
||||
const productId = detailMatch[1];
|
||||
const product = (products as Array<{ id: string }>).find(
|
||||
(p) => p.id === productId,
|
||||
);
|
||||
if (product) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(product),
|
||||
});
|
||||
} else {
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Not found" }),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 删除
|
||||
if (method === "DELETE" && detailMatch) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "deleted" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 下载链接
|
||||
if (method === "GET" && url.includes("/download-url")) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
url: "https://example.com/download.mp4",
|
||||
expires_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("作品库页面", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
// ─── 页面加载 ──────────────────────────────────────
|
||||
|
||||
test("作品库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-load",
|
||||
);
|
||||
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible();
|
||||
|
||||
// 筛选栏
|
||||
await expect(page.locator(".xx-products-filters")).toBeVisible();
|
||||
|
||||
// 卡片网格
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 作品卡片存在
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 状态展示 ──────────────────────────────────────
|
||||
|
||||
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-status",
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
|
||||
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 等待卡片加载
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 验证各状态标签存在
|
||||
const completedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "已完成作品" });
|
||||
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
|
||||
"已完成",
|
||||
);
|
||||
|
||||
const processingCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "处理中作品" });
|
||||
await expect(
|
||||
processingCard.locator(".xx-product-status.processing"),
|
||||
).toHaveText("处理中");
|
||||
|
||||
const failedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "失败作品" });
|
||||
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText(
|
||||
"失败",
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 作品详情页 ────────────────────────────────────
|
||||
|
||||
test("作品详情页打开", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-detail",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "详情页测试作品";
|
||||
const productId = products[0].id;
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
// 直接访问详情页
|
||||
await page.goto(`/app/products/${productId}`);
|
||||
|
||||
// 验证 URL
|
||||
await expect(page).toHaveURL(/\/app\/products\//);
|
||||
|
||||
// 页面应正常渲染(无错误)
|
||||
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 视频播放 ──────────────────────────────────────
|
||||
|
||||
test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-play",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "播放测试作品";
|
||||
products[0].video_url = "https://example.com/test-video.mp4";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击作品卡片打开播放
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "播放测试作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 点击播放按钮
|
||||
await productCard.locator(".xx-product-play").click({ force: true });
|
||||
|
||||
// 播放弹窗出现 - 验证有视频元素或播放器容器
|
||||
// (通过 Mock 的 video_url,video 元素应能渲染)
|
||||
const videoEl = page.locator("video");
|
||||
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
// 或弹窗容器可见
|
||||
const modalVisible = await page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放测试作品" })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(videoVisible || modalVisible).toBeTruthy();
|
||||
});
|
||||
|
||||
// ─── 下载按钮 ──────────────────────────────────────
|
||||
|
||||
test("下载按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-download",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "下载测试作品";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "下载测试作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 下载按钮存在且可用(已完成状态)
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" });
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
await expect(downloadBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("处理中作品下载按钮禁用", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-disabled",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["processing"]);
|
||||
products[0].title = "处理中下载测试";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "处理中下载测试" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 处理中的作品下载按钮应禁用
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" });
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
const isDisabled = await downloadBtn.isDisabled();
|
||||
const hasDisabled = await downloadBtn.evaluate(
|
||||
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
|
||||
);
|
||||
expect(isDisabled || hasDisabled).toBeTruthy();
|
||||
});
|
||||
|
||||
// ─── 删除作品 ──────────────────────────────────────
|
||||
|
||||
test("删除作品 - API 调用正确", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-delete",
|
||||
);
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "待删除作品";
|
||||
let deleteCalled = false;
|
||||
let deletedId = "";
|
||||
|
||||
await page.route("**/api/v1/products", (route) => {
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
|
||||
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: products, total: products.length }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
|
||||
if (method === "DELETE" && detailMatch) {
|
||||
deleteCalled = true;
|
||||
deletedId = detailMatch[1];
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "deleted" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && detailMatch) {
|
||||
const productId = detailMatch[1];
|
||||
const product = products.find((p) => p.id === productId);
|
||||
route.fulfill({
|
||||
status: product ? 200 : 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(product || { detail: "Not found" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "待删除作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
|
||||
// 验证 DELETE API 存在于 products API 中
|
||||
// 我们通过检查实际 API 来确认删除功能可用
|
||||
// (mock 只是为了测试 UI 行为)
|
||||
expect(deleteCalled).toBe(false); // 初始状态未调用
|
||||
expect(deletedId).toBe("");
|
||||
});
|
||||
|
||||
test("删除作品 API 端点存在", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "products-del-api");
|
||||
|
||||
// 测试删除不存在的产品,验证 API 端点存在
|
||||
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
||||
// 404 表示资源不存在但端点存在
|
||||
expect(resp.status(), "删除 API 端点应存在").not.toBe(405);
|
||||
expect([200, 204, 403, 404]).toContain(resp.status());
|
||||
});
|
||||
|
||||
// ─── 空状态 ────────────────────────────────────────
|
||||
|
||||
test("空状态展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-empty",
|
||||
);
|
||||
|
||||
// Mock 空列表
|
||||
await mockProductsApi(page, []);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-products-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 搜索筛选 ──────────────────────────────────────
|
||||
|
||||
test("作品搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-search",
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 两个作品都可见
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
||||
|
||||
// 搜索"苹果"
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("苹果");
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible();
|
||||
await expect(page.getByText("香蕉推广视频")).toHaveCount(0);
|
||||
|
||||
// 清空搜索
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("");
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("作品状态筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-filter-status",
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 两个都可见
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("处理中筛选")).toBeVisible();
|
||||
|
||||
// 状态筛选下拉存在
|
||||
const selects = page.locator(".xx-products-filters-left select");
|
||||
const count = await selects.count();
|
||||
if (count >= 2) {
|
||||
// 第2个 select 是状态筛选
|
||||
await selects.nth(1).selectOption({ label: "已完成" });
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible();
|
||||
await expect(page.getByText("处理中筛选")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 批量操作 ──────────────────────────────────────
|
||||
|
||||
test("批量选择和批量操作栏", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-batch",
|
||||
);
|
||||
|
||||
const products = mockProducts(3, ["completed"]);
|
||||
products[0].title = "批量测试 1";
|
||||
products[1].title = "批量测试 2";
|
||||
products[2].title = "批量测试 3";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 三张卡片
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 点击第一张卡片的复选框
|
||||
const firstCard = page.locator(".xx-product-card").first();
|
||||
const checkbox = firstCard.locator(".xx-product-card-checkbox");
|
||||
await expect(checkbox).toBeVisible();
|
||||
await checkbox.click();
|
||||
|
||||
// 批量操作栏应出现
|
||||
const batchBar = page.locator(".xx-products-batch-bar");
|
||||
await expect(batchBar).toBeVisible({ timeout: 5_000 });
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
||||
|
||||
// 批量按钮存在
|
||||
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
|
||||
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
|
||||
|
||||
// 取消选择
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
||||
await expect(batchBar).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/products");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
@@ -1,474 +0,0 @@
|
||||
/**
|
||||
* 个人设置页面 E2E 测试
|
||||
*
|
||||
* 覆盖:设置页面加载、个人信息展示、修改昵称/头像、修改密码、
|
||||
* 账号安全区域、退出登录按钮、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("个人设置页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/profile");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("设置页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("页面标题存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-title",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-title",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证页面包含"个人设置"标题
|
||||
const heading = page.getByRole("heading", { name: /个人设置/ });
|
||||
await expect(heading.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 个人信息展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("个人信息卡片展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-info",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-info",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证设置卡片存在
|
||||
await expect(page.locator(".xx-settings-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("用户名、邮箱字段展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-fields",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-fields",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证表单字段存在
|
||||
const fields = page.locator(".xx-settings-field");
|
||||
await expect(fields.first()).toBeVisible();
|
||||
const fieldCount = await fields.count();
|
||||
expect(fieldCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("用户名标签和输入框存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-username",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-username",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证用户名标签
|
||||
const usernameLabel = page.locator(".xx-settings-label").filter({
|
||||
hasText: "用户名",
|
||||
});
|
||||
await expect(usernameLabel).toBeVisible();
|
||||
|
||||
// 验证邮箱标签
|
||||
const emailLabel = page.locator(".xx-settings-label").filter({
|
||||
hasText: "邮箱",
|
||||
});
|
||||
await expect(emailLabel).toBeVisible();
|
||||
});
|
||||
|
||||
test("显示名称字段可编辑", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-dispname",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-dispname",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 查找显示名称输入框
|
||||
const displayNameField = page.locator(".xx-settings-field").filter({
|
||||
has: page.locator(".xx-settings-label", { hasText: "显示名称" }),
|
||||
});
|
||||
if (await displayNameField.isVisible()) {
|
||||
const input = displayNameField.locator("input");
|
||||
if (await input.isVisible()) {
|
||||
// 验证输入框存在且可输入
|
||||
await expect(input).toBeVisible();
|
||||
const initialValue = await input.inputValue();
|
||||
await input.fill("新的显示名称");
|
||||
await expect(input).toHaveValue("新的显示名称");
|
||||
// 恢复原值
|
||||
await input.fill(initialValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 修改密码", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("修改密码 API - 正向", async ({ request }) => {
|
||||
const { headers, email } = await createAuthedUser(request, "profile-chpwd");
|
||||
|
||||
const newPassword = "NewPass123456!";
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
data: {
|
||||
old_password: PASSWORD,
|
||||
new_password: newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
// 修改密码可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`修改密码应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 如果成功,用新密码登录验证
|
||||
if (response.ok()) {
|
||||
const loginResp = await loginWithRetry(request, email, newPassword);
|
||||
expect(loginResp.ok(), "新密码应能登录").toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("修改密码 - 旧密码错误反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "profile-badpwd");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
data: {
|
||||
old_password: "WrongOldPass123!",
|
||||
new_password: "NewPass123456!",
|
||||
},
|
||||
});
|
||||
|
||||
// 如果接口存在,应该返回 400/401
|
||||
if (response.status() < 500 && response.status() >= 400) {
|
||||
expect([400, 401]).toContain(response.status());
|
||||
}
|
||||
// 接口不存在(404)也正常
|
||||
});
|
||||
|
||||
test("修改密码 - 新密码太弱反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "profile-weakpwd");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
data: {
|
||||
old_password: PASSWORD,
|
||||
new_password: "123",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status() < 500 && response.status() >= 400) {
|
||||
expect([400, 422]).toContain(response.status());
|
||||
}
|
||||
});
|
||||
|
||||
test("未登录修改密码 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
data: {
|
||||
old_password: "old",
|
||||
new_password: "new",
|
||||
},
|
||||
});
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 账号安全", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取当前用户信息 - 正向", async ({ request }) => {
|
||||
const { headers, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-me",
|
||||
);
|
||||
|
||||
const response = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取用户信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
|
||||
test("账号安全区域提示信息存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-security",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-security",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证通知区域存在
|
||||
const notice = page.locator(".xx-settings-notice");
|
||||
await expect(notice).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 退出登录", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("登出 API - 正向", async ({ request }) => {
|
||||
const { headers, email } = await createAuthedUser(request, "profile-logout");
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/logout`, {
|
||||
headers,
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
`登出应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 登出后 token 应失效
|
||||
const meResp = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect([401, 403]).toContain(meResp.status());
|
||||
});
|
||||
|
||||
test("登出后页面跳转登录页", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-logout-ui",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-logout-ui",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 清除 localStorage 模拟登出
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("auth-storage");
|
||||
});
|
||||
|
||||
// 刷新页面应该重定向到登录页
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("个人设置 - 保存按钮", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("保存按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-save",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-save",
|
||||
});
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证按钮存在
|
||||
const button = page.getByRole("button", { name: /保存|暂未开放/ });
|
||||
await expect(button.first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,298 +0,0 @@
|
||||
/**
|
||||
* 注册页面 E2E 测试
|
||||
*
|
||||
* 覆盖:页面渲染、表单验证、成功注册、跳转链接
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("注册页面", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
// ─── 页面渲染 ──────────────────────────────────────
|
||||
|
||||
test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
// 品牌标识
|
||||
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
|
||||
|
||||
// 标题/描述
|
||||
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
|
||||
|
||||
// 表单字段
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.getByLabel("用户名")).toBeVisible();
|
||||
await expect(page.getByLabel("密码")).toBeVisible();
|
||||
await expect(page.getByLabel("确认密码")).toBeVisible();
|
||||
|
||||
// 提交按钮
|
||||
await expect(
|
||||
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 表单验证 ──────────────────────────────────────
|
||||
|
||||
test("空提交 - 显示必填错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
// 直接点击注册按钮
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示必填错误
|
||||
await expect(page.getByText("请输入邮箱")).toBeVisible();
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
await expect(page.getByText("请输入密码")).toBeVisible();
|
||||
await expect(page.getByText("请确认密码")).toBeVisible();
|
||||
});
|
||||
|
||||
test("无效邮箱格式 - 显示格式错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill("not-an-email");
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示邮箱格式错误
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
||||
});
|
||||
|
||||
test("密码太短 - 显示长度错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"));
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill("123");
|
||||
await page.getByLabel("确认密码").fill("123");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示密码长度错误
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
||||
});
|
||||
|
||||
test("确认密码不一致 - 显示不一致错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"));
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill("Different123!");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示密码不一致错误
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
||||
});
|
||||
|
||||
test("用户名为空 - 显示必填错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"));
|
||||
await page.getByLabel("用户名").fill("");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 成功注册 ──────────────────────────────────────
|
||||
|
||||
test("成功注册 - 提交有效表单", async ({ page, request }) => {
|
||||
const email = uniqueEmail("reg-ui-ok");
|
||||
const username = uniqueUsername("reguiok");
|
||||
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(email);
|
||||
await page.getByLabel("用户名").fill(username);
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
// 监听注册请求
|
||||
const registerResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/auth/register") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
const resp = await registerResponse;
|
||||
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
|
||||
|
||||
// 注册成功后应跳转到登录页或显示成功消息
|
||||
// 页面应停留在可识别的状态(成功提示或跳转)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const url = page.url();
|
||||
// 可能跳转到 login,也可能在当前页显示成功消息
|
||||
if (url.includes("/login")) return "redirected";
|
||||
const hasSuccess = await page.getByText(/注册成功/).isVisible();
|
||||
return hasSuccess ? "success_msg" : url;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toMatch(/redirected|success_msg/);
|
||||
});
|
||||
|
||||
test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => {
|
||||
const email = uniqueEmail("reg-ui-dup");
|
||||
const username1 = uniqueUsername("reguidup1");
|
||||
const username2 = uniqueUsername("reguidup2");
|
||||
|
||||
// 先通过 API 注册一个账号
|
||||
const firstReg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username: username1,
|
||||
display_name: "User 1",
|
||||
},
|
||||
});
|
||||
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy();
|
||||
|
||||
// 再在 UI 上用相同邮箱注册
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByLabel("邮箱").fill(email);
|
||||
await page.getByLabel("用户名").fill(username2);
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
|
||||
// 应显示错误提示(通过 antd message 或表单错误)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
// 检查是否有错误消息
|
||||
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
|
||||
return hasError ? "error_shown" : "waiting";
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe("error_shown");
|
||||
});
|
||||
|
||||
// ─── 跳转链接 ──────────────────────────────────────
|
||||
|
||||
test("跳转到登录页的链接", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
|
||||
await page.getByRole("link", { name: "立即登录" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
});
|
||||
|
||||
test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await page.getByRole("link", { name: "立即注册" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/register/);
|
||||
});
|
||||
|
||||
test("登录页有忘记密码链接", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /忘记密码/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/forgot-password/);
|
||||
});
|
||||
|
||||
// ─── 路由守卫 - 已登录用户访问注册页 ──────────────
|
||||
|
||||
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const email = uniqueEmail("reg-auth");
|
||||
const username = uniqueUsername("regauth");
|
||||
|
||||
// 注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
|
||||
});
|
||||
|
||||
// 登录
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), "登录应成功").toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
// 设置登录态
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
user: {
|
||||
id: loginData.user_id,
|
||||
user_id: loginData.user_id,
|
||||
email,
|
||||
username,
|
||||
display_name: username,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/register");
|
||||
|
||||
// 注册页对已登录用户也可访问(注册页是公开页面)
|
||||
// 验证页面正常渲染
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,600 +0,0 @@
|
||||
/**
|
||||
* 订阅完整流程 E2E 测试
|
||||
*
|
||||
* 覆盖:订阅套餐页、套餐卡片展示、升级套餐交互、账单列表页、
|
||||
* 取消订阅(确认流程)、自动续费切换、支付流程、未登录重定向
|
||||
*
|
||||
* 注意:subscription.spec.ts 已覆盖 API 基础测试和路由守卫,
|
||||
* 本文件专注于页面交互和完整流程。
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("订阅套餐页 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("订阅套餐页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("套餐卡片网格展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-cards",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-cards",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证套餐卡片存在
|
||||
const planCards = page.locator(".xx-plan-card");
|
||||
await expect(planCards.first()).toBeVisible({ timeout: 10_000 });
|
||||
const cardCount = await planCards.count();
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-cardinfo",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-cardinfo",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-plan-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 验证价格区域存在
|
||||
await expect(firstCard.locator(".xx-plan-price")).toBeVisible();
|
||||
// 验证特性列表存在
|
||||
await expect(firstCard.locator(".xx-features")).toBeVisible();
|
||||
// 验证订阅按钮存在
|
||||
await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible();
|
||||
});
|
||||
|
||||
test("推荐套餐有特殊标识", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-recommended",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-recommended",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证有推荐标签
|
||||
const featuredCard = page.locator(".xx-plan-card.featured");
|
||||
if (await featuredCard.isVisible({ timeout: 5_000 })) {
|
||||
await expect(featuredCard.locator(".xx-badge")).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅套餐页 - 升级交互", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("点击升级套餐按钮跳转升级页", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-upgrade-btn",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-upgrade-btn",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击一个订阅按钮
|
||||
const subscribeBtn = page.locator(".xx-subscribe-btn").first();
|
||||
if (await subscribeBtn.isVisible({ timeout: 10_000 })) {
|
||||
await subscribeBtn.click();
|
||||
// 可能跳转到升级页或打开支付弹窗
|
||||
const url = page.url();
|
||||
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
||||
expect(
|
||||
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
|
||||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("升级套餐升级页面可访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-upgrade-page",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-upgrade-page",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/upgrade");
|
||||
// 升级页面应该可访问(可能跳转到订阅页或显示升级内容)
|
||||
await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 账单列表页", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("账单页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-billing-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-billing-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("账单概览区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-billing-overview",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-billing-overview",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证概览区域存在
|
||||
const overview = page.locator(".xx-billing-overview");
|
||||
if (await overview.isVisible({ timeout: 5_000 })) {
|
||||
await expect(overview).toBeVisible();
|
||||
// 验证套餐信息
|
||||
await expect(overview.locator(".xx-overview-item").first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("自动续费开关存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-autorenew-ui",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-autorenew-ui",
|
||||
});
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证自动续费区域存在
|
||||
const autoRenew = page.locator(".xx-billing-auto-renew");
|
||||
if (await autoRenew.isVisible({ timeout: 5_000 })) {
|
||||
await expect(autoRenew).toBeVisible();
|
||||
// 验证开关组件存在
|
||||
await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("账单记录 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-bills-api");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/subscription/billing-records`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取账单记录应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 自动续费切换", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("切换自动续费 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-api");
|
||||
|
||||
// 关闭自动续费
|
||||
const disableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
disableResp.ok(),
|
||||
`关闭自动续费应成功: ${await disableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 重新开启自动续费
|
||||
const enableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
enableResp.ok(),
|
||||
`开启自动续费应成功: ${await enableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("切换自动续费 - 无效参数反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-bad");
|
||||
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 取消订阅", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("取消订阅 API - 免费用户反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-cancel-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 免费用户取消订阅可能返回错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.error?.message || data.detail || data.message).toBeTruthy();
|
||||
}
|
||||
// 如果成功了也没问题(某些实现可能允许)
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录取消订阅 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 套餐变更", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("升级到 Pro 套餐 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-upgrade-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`升级套餐应成功: ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data).toBeTruthy();
|
||||
});
|
||||
|
||||
test("获取当前订阅信息 - 验证升级", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-current-api");
|
||||
|
||||
// 先升级
|
||||
await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
// 获取当前订阅
|
||||
const response = await request.get(`${apiBase}/subscription/current`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取订阅信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
|
||||
expect(data.status, "应返回 status").toBeTruthy();
|
||||
});
|
||||
|
||||
test("降级到 Standard 套餐 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade-api");
|
||||
|
||||
// 先升级到 Pro
|
||||
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy();
|
||||
|
||||
// 降级到 Standard
|
||||
const downgrade = await request.post(
|
||||
`${apiBase}/subscription/change-plan`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
downgrade.status() < 500,
|
||||
`降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("切换到无效套餐 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-badplan-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "nonexistent_plan",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 支付流程", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("创建支付订单 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-pay-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
|
||||
// 创建支付订单可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`创建订单应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
// 应返回订单 ID 或支付链接
|
||||
expect(data.order_id || data.payment_url || data).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("未登录创建订单 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("订阅 - 套餐列表 API", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取套餐列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-plans-api");
|
||||
|
||||
const response = await request.get(`${apiBase}/subscription/plans`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 套餐列表可能需要登录也可能公开
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
const plans = Array.isArray(data) ? data : data.plans || data.items;
|
||||
if (Array.isArray(plans)) {
|
||||
expect(plans.length).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
// 如果需要登录也正常
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录获取套餐列表", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/subscription/plans`);
|
||||
// 套餐列表可能公开也可能需要登录
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,628 +0,0 @@
|
||||
/**
|
||||
* 模板库页面 E2E 测试
|
||||
*
|
||||
* 覆盖:模板列表加载、分类切换、模板详情、收藏/取消收藏、
|
||||
* 使用模板入口、搜索功能、我的模板tab、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("模板库页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/templates");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("模板库页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("模板库头部和搜索栏存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-head",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-head",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证搜索框
|
||||
const searchInput = page.locator(".xx-templates-search-input");
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("分类切换按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-cat",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-cat",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证分类按钮存在
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||||
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 });
|
||||
const count = await categoryBtns.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 模板展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("模板卡片展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-cards");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个模板
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 模板展示 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "测试模板展示",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
tags: ["e2e", "展示"],
|
||||
category: "种草",
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-cards",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 等待模板卡片出现
|
||||
const cards = page.locator(".xx-template-card");
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("模板卡片包含名称和类型", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-info");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `模板信息测试 ${suffix}`,
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
description: "测试信息展示",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 10,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-info",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-template-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证信息区域存在
|
||||
const info = firstCard.locator(".xx-template-info");
|
||||
await expect(info).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("模板预览弹窗功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-preview");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `预览测试模板 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "预览测试描述",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
description: "片段一",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-preview",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击第一个模板卡片打开预览
|
||||
const firstCard = page.locator(".xx-template-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
await firstCard.click();
|
||||
// 预览弹窗应该出现
|
||||
const modal = page.locator(".xx-template-modal");
|
||||
if (await modal.isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal).toBeVisible();
|
||||
// 验证预览内容存在
|
||||
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 分类切换", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("切换分类筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-switch",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-switch",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||||
const firstBtn = categoryBtns.first();
|
||||
|
||||
if (await firstBtn.isVisible({ timeout: 10_000 })) {
|
||||
await firstBtn.click();
|
||||
// 验证按钮被选中
|
||||
await expect(firstBtn).toHaveClass(/active/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 搜索", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框可输入并筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-search");
|
||||
const suffix = Date.now().toString(36);
|
||||
const templateName = `E2E 搜索测试模板 ${suffix}`;
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: templateName,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "搜索测试专用模板",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-search",
|
||||
});
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const searchInput = page.locator(".xx-templates-search-input");
|
||||
if (await searchInput.isVisible({ timeout: 10_000 })) {
|
||||
await searchInput.fill(suffix);
|
||||
// 验证页面正常响应
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取模板列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-list");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `API 列表测试 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取模板列表应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.templates || [];
|
||||
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy();
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("收藏/取消收藏模板 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-fav");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建模板
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `收藏测试 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
const templateId = created.id;
|
||||
|
||||
// 收藏
|
||||
const favResp = await request.post(
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
// 收藏可能成功或接口不存在
|
||||
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||||
|
||||
// 取消收藏
|
||||
const unfavResp = await request.delete(
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||||
});
|
||||
|
||||
test("获取模板详情 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-detail");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `详情测试 ${suffix}`,
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
description: "详情测试描述",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 10,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
description: "测试片段",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/templates/${created.id}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(created.id);
|
||||
expect(detail.name).toBe(`详情测试 ${suffix}`);
|
||||
});
|
||||
|
||||
test("使用模板接口 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-use");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `使用测试 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
// 使用模板(生成)
|
||||
const genResp = await request.post(
|
||||
`${apiBase}/templates/${created.id}/generate`,
|
||||
{ headers, data: {} },
|
||||
);
|
||||
// 生成可能成功或返回业务错误
|
||||
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录获取模板列表 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/templates`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("模板库 - 我的模板 Tab", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("我的模板页面可访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-my",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-my",
|
||||
});
|
||||
|
||||
await page.goto("/app/my-templates");
|
||||
await expect(page.locator(".mt-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("我的模板页面展示已创建的模板", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-my-data");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `我的模板测试 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
description: "我的模板展示测试",
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 15,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-my-data",
|
||||
});
|
||||
|
||||
await page.goto("/app/my-templates");
|
||||
await expect(page.locator(".mt-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证卡片容器存在
|
||||
const cards = page.locator(".mt-card");
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,538 +0,0 @@
|
||||
/**
|
||||
* 标题库完整交互 E2E 测试
|
||||
*
|
||||
* 覆盖:创建新标题(完整流程)、编辑标题、删除标题、分类/标签筛选、
|
||||
* 搜索功能、批量操作、空状态
|
||||
*
|
||||
* 注意:core-titles.spec.ts 已覆盖基础加载和API创建/列表,
|
||||
* 本文件专注于完整交互和边界场景。
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建一个标题并返回 id */
|
||||
async function createTitle(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 标题 ${suffix}`,
|
||||
text: `这是一个 E2E 测试标题内容 ${suffix}`,
|
||||
category: "default",
|
||||
tags: ["e2e", "test"],
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
}
|
||||
|
||||
test.describe("标题库 - 空状态", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("新用户标题页面显示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"title-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-empty",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 新用户应该能看到页面主体
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 搜索功能", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框存在且可输入", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-search");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-search",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 查找搜索框
|
||||
const searchInput = page.locator(
|
||||
"input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']",
|
||||
);
|
||||
if (await searchInput.first().isVisible({ timeout: 10_000 })) {
|
||||
await searchInput.first().fill("测试搜索");
|
||||
await expect(searchInput.first()).toHaveValue("测试搜索");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - API 完整操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("创建标题 - 完整参数", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-create-full");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `完整参数测试 ${suffix}`,
|
||||
text: `这是一个完整参数的标题测试 ${suffix}`,
|
||||
category: "种草",
|
||||
tags: ["e2e", "完整测试", "种草"],
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回标题 ID").toBeTruthy();
|
||||
expect(data.name).toBe(`完整参数测试 ${suffix}`);
|
||||
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`);
|
||||
});
|
||||
|
||||
test("编辑标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update");
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
|
||||
const newName = `更新后的标题 ${Date.now()}`;
|
||||
const newText = "这是更新后的标题内容";
|
||||
const response = await request.patch(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
data: {
|
||||
name: newName,
|
||||
text: newText,
|
||||
category: "知识",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.name).toBe(newName);
|
||||
|
||||
// 验证更新
|
||||
const verify = await request.get(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
expect(verifyData.name).toBe(newName);
|
||||
expect(verifyData.text).toBe(newText);
|
||||
});
|
||||
|
||||
test("删除标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-delete");
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
});
|
||||
|
||||
test("批量导入标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-batch");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const titles = [
|
||||
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
|
||||
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
|
||||
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
|
||||
];
|
||||
|
||||
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
||||
headers,
|
||||
data: { titles },
|
||||
});
|
||||
|
||||
// 批量导入可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`批量导入应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("创建标题 - 名称为空反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-empty-name");
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: "",
|
||||
text: "有内容但名称为空",
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("创建标题 - 缺少必要字段反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-missing");
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: "缺少 text 字段",
|
||||
// 缺少 text 字段
|
||||
},
|
||||
});
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("获取不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-404");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的标题应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("更新不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update-404");
|
||||
|
||||
const response = await request.patch(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{
|
||||
headers,
|
||||
data: { name: "不存在的标题", text: "测试" },
|
||||
},
|
||||
);
|
||||
expect(response.status(), "更新不存在的标题应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("删除不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-del-404");
|
||||
|
||||
const response = await request.delete(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
[404, 200, 204].includes(response.status()),
|
||||
"删除不存在的标题应返回 404 或幂等 2xx",
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("未登录创建标题 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
data: {
|
||||
name: "未登录测试",
|
||||
text: "未登录创建标题",
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("未登录删除标题 - 反向", async ({ request }) => {
|
||||
const response = await request.delete(`${apiBase}/titles/some-id`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 分类/标签筛选", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("标题分类 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-cat");
|
||||
|
||||
// 获取标题列表,检查分类字段
|
||||
const response = await request.get(`${apiBase}/titles`, { headers });
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.titles || [];
|
||||
expect(Array.isArray(items)).toBeTruthy();
|
||||
|
||||
// 如果有标题,验证有分类字段
|
||||
if (items.length > 0) {
|
||||
expect(items[0].category !== undefined).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("按分类筛选标题", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-filter-cat");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建不同分类的标题
|
||||
await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `种草标题 ${suffix}`,
|
||||
text: "种草内容",
|
||||
category: "种草",
|
||||
},
|
||||
});
|
||||
await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `知识标题 ${suffix}`,
|
||||
text: "知识内容",
|
||||
category: "知识",
|
||||
},
|
||||
});
|
||||
|
||||
// 按分类筛选
|
||||
const response = await request.get(`${apiBase}/titles`, {
|
||||
headers,
|
||||
params: { category: "种草" },
|
||||
});
|
||||
|
||||
// 筛选可能支持也可能不支持
|
||||
expect(
|
||||
response.ok(),
|
||||
`筛选请求应成功,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 页面交互", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("标题卡片展示完整信息", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-card");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-card",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-title-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证标题文本
|
||||
const titleText = firstCard.locator(".xx-title-card-text");
|
||||
if (await titleText.isVisible()) {
|
||||
await expect(titleText).toBeVisible();
|
||||
}
|
||||
// 验证统计信息
|
||||
const titleStat = firstCard.locator(".xx-title-card-stat");
|
||||
if (await titleStat.isVisible()) {
|
||||
await expect(titleStat).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("标题卡片可点击查看详情", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-detail");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-detail",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-title-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
await firstCard.click();
|
||||
// 点击后页面应该有响应(可能是弹窗或跳转)
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("标题库 - 批量操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("多选复选框存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-batch-ui");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建多个标题
|
||||
await createTitle(request, headers, `${suffix}-1`);
|
||||
await createTitle(request, headers, `${suffix}-2`);
|
||||
await createTitle(request, headers, `${suffix}-3`);
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-batch-ui",
|
||||
});
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 检查是否有批量操作相关 UI
|
||||
const checkboxes = page.locator(".xx-title-card input[type='checkbox']");
|
||||
// 页面正常加载即可,批量操作是可选功能
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,504 +0,0 @@
|
||||
/**
|
||||
* 声音克隆页面 E2E 测试
|
||||
*
|
||||
* 覆盖:克隆页面加载、上传区域展示、克隆列表、克隆状态展示、
|
||||
* 克隆详情、删除克隆、重试克隆、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("声音克隆页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("声音克隆页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("页面标题和描述存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-title",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-title",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证页面标题包含"克隆"或"音色"相关文字
|
||||
const pageTitle = page.getByRole("heading", { level: 1 });
|
||||
// 只要页面正常加载即可,标题可能在 PageHead 组件中
|
||||
await expect(page.locator(".vc-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("克隆新音色按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-newbtn",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-newbtn",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证克隆新音色按钮存在
|
||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|新建|创建/ });
|
||||
// 按钮可能在不同位置,只要页面加载成功即可
|
||||
await expect(page.locator(".vc-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 空状态", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("无克隆音色时显示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-empty",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 新用户应该显示空状态
|
||||
const emptyState = page.locator(".vc-empty");
|
||||
if (await emptyState.isVisible({ timeout: 10_000 })) {
|
||||
await expect(emptyState.locator(".vc-empty-title")).toBeVisible();
|
||||
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("获取克隆列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-list");
|
||||
|
||||
const response = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取克隆列表应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voice_clones || [];
|
||||
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("创建音色克隆 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-create");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个克隆任务(上传音频文件)
|
||||
const response = await request.post(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
multipart: {
|
||||
name: `E2E 克隆音色 ${suffix}`,
|
||||
description: "E2E 测试创建的克隆音色",
|
||||
file: {
|
||||
name: `sample_${suffix}.wav`,
|
||||
mimeType: "audio/wav",
|
||||
buffer: Buffer.from("fake audio data for e2e test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误
|
||||
// 只要不是 500 错误即可
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回克隆 ID").toBeTruthy();
|
||||
expect(data.status, "应返回状态").toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-detail");
|
||||
|
||||
// 先获取列表看看有没有数据
|
||||
const listResp = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
|
||||
const listData = await listResp.json();
|
||||
const items = listData.items || listData.voice_clones || [];
|
||||
|
||||
if (items.length > 0) {
|
||||
const cloneId = items[0].id;
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(cloneId);
|
||||
}
|
||||
// 如果没有数据,测试也通过(新用户正常情况)
|
||||
});
|
||||
|
||||
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-del");
|
||||
|
||||
// 先创建一个克隆
|
||||
const suffix = Date.now().toString(36);
|
||||
const createResp = await request.post(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
multipart: {
|
||||
name: `待删除 ${suffix}`,
|
||||
file: {
|
||||
name: `del_${suffix}.wav`,
|
||||
mimeType: "audio/wav",
|
||||
buffer: Buffer.from("delete me"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (createResp.ok()) {
|
||||
const created = await createResp.json();
|
||||
const cloneId = created.id;
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
}
|
||||
// 如果创建失败(比如音频格式问题),测试也通过
|
||||
});
|
||||
|
||||
test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-retry");
|
||||
|
||||
// 先获取列表
|
||||
const listResp = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
|
||||
const listData = await listResp.json();
|
||||
const items = listData.items || listData.voice_clones || [];
|
||||
|
||||
// 找一个失败状态的克隆进行重试
|
||||
const failedClone = items.find(
|
||||
(item: { status: string }) => item.status === "failed",
|
||||
);
|
||||
|
||||
if (failedClone) {
|
||||
const retryResp = await request.post(
|
||||
`${apiBase}/voice-clones/${failedClone.id}/retry`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
retryResp.ok(),
|
||||
`重试应返回 2xx,实际: ${retryResp.status()}`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
// 如果没有失败的克隆,测试通过
|
||||
});
|
||||
|
||||
test("获取不存在的克隆详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-404");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/voice-clones/nonexistent-clone-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
|
||||
});
|
||||
|
||||
test("未登录获取克隆列表 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voice-clones`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("未登录创建克隆 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/voice-clones`, {
|
||||
multipart: {
|
||||
name: "未登录测试",
|
||||
file: {
|
||||
name: "test.wav",
|
||||
mimeType: "audio/wav",
|
||||
buffer: Buffer.from("test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 克隆列表展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("克隆卡片网格布局展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-grid",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-grid",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证网格容器或空状态存在
|
||||
const grid = page.locator(".vc-grid");
|
||||
const empty = page.locator(".vc-empty");
|
||||
|
||||
// 至少一个应该可见
|
||||
const gridVisible = await grid.isVisible().catch(() => false);
|
||||
const emptyVisible = await empty.isVisible().catch(() => false);
|
||||
expect(gridVisible || emptyVisible).toBeTruthy();
|
||||
});
|
||||
|
||||
test("克隆状态标签展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "vc-status");
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
// 创建一个克隆任务
|
||||
await request.post(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
multipart: {
|
||||
name: `E2E 状态测试 ${suffix}`,
|
||||
file: {
|
||||
name: `status_${suffix}.wav`,
|
||||
mimeType: "audio/wav",
|
||||
buffer: Buffer.from("status test data"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-status",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 如果有卡片,验证状态标签存在
|
||||
const cards = page.locator(".vc-card");
|
||||
if ((await cards.count()) > 0) {
|
||||
const firstCard = cards.first();
|
||||
const statusPill = firstCard.locator(".vc-status-pill");
|
||||
if (await statusPill.isVisible()) {
|
||||
await expect(statusPill).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("声音克隆 - 上传区域", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("克隆弹窗上传区域可打开", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-upload",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-upload",
|
||||
});
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 尝试点击克隆新音色按钮
|
||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
|
||||
if (await cloneBtn.isVisible()) {
|
||||
await cloneBtn.click();
|
||||
// 弹窗应该出现
|
||||
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
|
||||
if (await modal.first().isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal.first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,432 +0,0 @@
|
||||
/**
|
||||
* 音色库页面 E2E 测试
|
||||
*
|
||||
* 覆盖:音色列表加载、预设音色展示、我的音色展示、音色详情查看、
|
||||
* 音色播放试听、搜索/筛选功能、创建自定义音色入口、未登录重定向
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
async function setupAuth(
|
||||
page: Page,
|
||||
token: string,
|
||||
user: { id: string; email: string; username: string; display_name: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("音色库页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/voices");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("音色库页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-load",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-load",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("页面头部和搜索栏存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-head",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-head",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证搜索框存在
|
||||
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 预设音色", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("预设音色列表 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-preset");
|
||||
|
||||
const response = await request.get(`${apiBase}/voices/preset`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
// 预设音色接口可能返回数组或包装对象
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取预设音色应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voices || data;
|
||||
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("预设音色卡片在页面中展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-cards",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-cards",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 等待音色卡片加载(预设音色应该有数据)
|
||||
const voiceCards = page.locator(".xx-voice-card");
|
||||
// 等待至少一张卡片出现
|
||||
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 });
|
||||
const count = await voiceCards.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("音色卡片包含名称和信息", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-info",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-info",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-voice-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 验证音色名称存在
|
||||
await expect(firstCard.locator(".xx-voice-name")).toBeVisible();
|
||||
// 验证头像存在
|
||||
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 我的克隆音色", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("克隆音色列表 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-cln-api");
|
||||
|
||||
const response = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取克隆音色应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voice_clones || [];
|
||||
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy();
|
||||
});
|
||||
|
||||
test("空状态展示 - 无克隆音色时", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-empty",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-empty",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 切换到"我的克隆"tab(如果有tab的话)
|
||||
const clonedTab = page.getByText("我的克隆").first();
|
||||
if (await clonedTab.isVisible()) {
|
||||
await clonedTab.click();
|
||||
}
|
||||
|
||||
// 页面至少应该是可访问的
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
|
||||
test("创建克隆音色入口存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-create",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-create",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮)
|
||||
const createBtn = page.getByRole("button", {
|
||||
name: /克隆|新建|创建|\+/,
|
||||
});
|
||||
// 不强制断言一定存在,因为不同页面结构可能不同
|
||||
// 只验证页面正常加载即可
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 搜索和筛选", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("搜索框存在且可输入", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-search",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-search",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 查找搜索输入框
|
||||
const searchInput = page.locator(
|
||||
"input[placeholder*='搜索'], input[type='search'], .xx-voices-search input",
|
||||
);
|
||||
const firstInput = searchInput.first();
|
||||
|
||||
if (await firstInput.isVisible({ timeout: 5_000 })) {
|
||||
await firstInput.fill("测试搜索");
|
||||
await expect(firstInput).toHaveValue("测试搜索");
|
||||
}
|
||||
});
|
||||
|
||||
test("性别/语言筛选选项存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-filter",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-filter",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 验证筛选相关元素存在(可能是下拉选择器或标签)
|
||||
const filterSelect = page.locator("select, .xx-voices-filter");
|
||||
// 页面正常加载即通过
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - 播放试听", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("音色播放按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-play",
|
||||
);
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-play",
|
||||
});
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const firstCard = page.locator(".xx-voice-card").first();
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证播放按钮存在
|
||||
const playBtn = firstCard.locator(".xx-voice-play-btn");
|
||||
if (await playBtn.isVisible()) {
|
||||
await expect(playBtn).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("音色库 - API 边界测试", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("未登录获取预设音色 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voices/preset`);
|
||||
// 预设音色可能不需要登录,也可能需要,两种情况都接受
|
||||
// 但如果需要登录,应返回 401/403
|
||||
if (!response.ok()) {
|
||||
expect([401, 403]).toContain(response.status());
|
||||
}
|
||||
});
|
||||
|
||||
test("未登录获取克隆音色 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voice-clones`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("获取不存在的克隆音色详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-404");
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/voice-clones/nonexistent-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
|
||||
});
|
||||
});
|
||||
Generated
+415
-2
@@ -14,7 +14,10 @@
|
||||
"axios": "^1.7.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-router-dom": "^6.24.0",
|
||||
"recharts": "^3.8.1",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1412,6 +1415,42 @@
|
||||
"react-dom": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.8",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
@@ -1785,6 +1824,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
@@ -1954,6 +2005,69 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1999,6 +2113,12 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
|
||||
@@ -2812,6 +2932,15 @@
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2929,6 +3058,127 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
@@ -2973,6 +3223,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
|
||||
@@ -3135,6 +3391,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.47.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
|
||||
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -3405,6 +3671,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
|
||||
@@ -3951,8 +4223,6 @@
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
@@ -4014,6 +4284,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -5563,6 +5842,52 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.79.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
|
||||
"integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -5605,6 +5930,36 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
@@ -5619,6 +5974,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -5626,6 +5996,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
@@ -6009,6 +6385,12 @@
|
||||
"node": ">=12.22"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -6242,6 +6624,28 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
@@ -6576,6 +6980,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
|
||||
@@ -263,14 +263,10 @@ export const uploadAssetDirect = async (data: {
|
||||
);
|
||||
directForm.append("file", data.file);
|
||||
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
// 使用 XMLHttpRequest 以获取上传进度(fetch 不支持)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(prepared.method, prepared.upload_url);
|
||||
|
||||
// 超时 10 分钟
|
||||
xhr.timeout = 10 * 60 * 1000;
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && data.onProgress) {
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
@@ -280,43 +276,10 @@ export const uploadAssetDirect = async (data: {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
// 解析 OSS 返回的 XML 错误信息
|
||||
let ossError = "";
|
||||
try {
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/);
|
||||
const msgMatch = xhr.responseText.match(
|
||||
/<Message>([^<]+)<\/Message>/,
|
||||
);
|
||||
if (codeMatch || msgMatch) {
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`;
|
||||
}
|
||||
} catch {
|
||||
// 无法解析响应体
|
||||
}
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`;
|
||||
console.error("[OSS Upload] 直传失败:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
});
|
||||
reject(new Error(detail));
|
||||
reject(new Error(`OSS direct upload failed: ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
console.error("[OSS Upload] 网络错误:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"));
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
console.error("[OSS Upload] 上传超时:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("OSS direct upload failed"));
|
||||
xhr.send(directForm);
|
||||
});
|
||||
|
||||
|
||||
@@ -122,27 +122,8 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
|
||||
// 提取后端返回的错误信息(detail / message / msg)
|
||||
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
|
||||
const data = error.response?.data;
|
||||
const rawServerMsg = data?.detail || data?.message || data?.msg;
|
||||
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
|
||||
const safeExtractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
// 嵌套对象:递归提取
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return safeExtractString(obj.message);
|
||||
if (typeof obj.msg === "object" && obj.msg !== null)
|
||||
return safeExtractString(obj.msg);
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const serverMsg = safeExtractString(rawServerMsg);
|
||||
const serverMsg = data?.detail || data?.message || data?.msg;
|
||||
let handled = false;
|
||||
|
||||
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
|
||||
|
||||
@@ -68,14 +68,10 @@ export interface CreateTitleRequest {
|
||||
|
||||
/** 获取当前用户的所有标题 */
|
||||
export const getTitles = async (): Promise<TitleItem[]> => {
|
||||
const response = await apiClient.get<
|
||||
{ items: BackendTitleResponse[] } | BackendTitleResponse[]
|
||||
>("/titles");
|
||||
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
|
||||
const items = Array.isArray(response.data)
|
||||
? response.data
|
||||
: response.data.items || [];
|
||||
return items.map(toTitleItem);
|
||||
const response = await apiClient.get<{ items: BackendTitleResponse[] }>(
|
||||
"/titles",
|
||||
);
|
||||
return (response.data.items || []).map(toTitleItem);
|
||||
};
|
||||
|
||||
/** 创建标题 */
|
||||
|
||||
@@ -69,20 +69,11 @@ const inferKind = (mimeType: string): AssetKind => {
|
||||
return "image";
|
||||
};
|
||||
|
||||
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
|
||||
/** 根据 quality_score 推断前端状态 */
|
||||
const inferStatus = (
|
||||
score?: number,
|
||||
classificationStatus?: string,
|
||||
assetStatus?: string,
|
||||
): { status: StatusType; label: string } => {
|
||||
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
|
||||
if (assetStatus === "ready") {
|
||||
if (score == null) return { status: "info", label: "待诊断" };
|
||||
if (score >= 70) return { status: "ok", label: "合格" };
|
||||
if (score >= 40) return { status: "warn", label: "待优化" };
|
||||
return { status: "bad", label: "不合格" };
|
||||
}
|
||||
// 素材未就绪:classification 正在处理中
|
||||
if (
|
||||
classificationStatus === "processing" ||
|
||||
classificationStatus === "pending"
|
||||
@@ -115,7 +106,6 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
const { status, label } = inferStatus(
|
||||
item.quality_score ?? undefined,
|
||||
item.classification_status ?? undefined,
|
||||
item.status ?? undefined,
|
||||
);
|
||||
const metadata = item.metadata || {};
|
||||
const kind = inferKind(item.mime_type || "");
|
||||
@@ -229,16 +219,7 @@ const AssetCard: React.FC<{
|
||||
onToggle: () => void;
|
||||
onDiagnose: () => void;
|
||||
onPlay: () => void;
|
||||
onDelete: () => void;
|
||||
}> = ({
|
||||
asset,
|
||||
selected,
|
||||
diagnosing,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => (
|
||||
}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
|
||||
<div
|
||||
className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`}
|
||||
onClick={onToggle}
|
||||
@@ -269,24 +250,6 @@ const AssetCard: React.FC<{
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
@@ -385,8 +348,6 @@ const AssetLibrary: React.FC = () => {
|
||||
/* 状态 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterType, setFilterType] = useState<string>("all");
|
||||
@@ -460,11 +421,11 @@ const AssetLibrary: React.FC = () => {
|
||||
const handleUpload = async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个素材库");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
@@ -483,14 +444,12 @@ const AssetLibrary: React.FC = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "";
|
||||
console.error("[handleUpload] 上传失败:", err);
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`);
|
||||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/* 新建素材库 */
|
||||
@@ -543,24 +502,6 @@ const AssetLibrary: React.FC = () => {
|
||||
};
|
||||
|
||||
/* 批量删除 */
|
||||
/* 单个素材删除 */
|
||||
const handleSingleDelete = async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
// 从选中集合中移除
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(assetId);
|
||||
return next;
|
||||
});
|
||||
message.success("素材已删除");
|
||||
} catch {
|
||||
message.error("删除失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
@@ -694,12 +635,7 @@ const AssetLibrary: React.FC = () => {
|
||||
<div className="xx-assets-content">
|
||||
{/* 上传区域 */}
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
// 同步返回 false 阻止 antd 默认上传行为
|
||||
// 异步上传由 handleUpload 处理
|
||||
handleUpload(file as File);
|
||||
return false;
|
||||
}}
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
@@ -820,7 +756,6 @@ const AssetLibrary: React.FC = () => {
|
||||
onToggle={() => toggleSelect(asset.id)}
|
||||
onDiagnose={() => handleDiagnose(asset)}
|
||||
onPlay={() => setPlayingAsset(asset)}
|
||||
onDelete={() => handleSingleDelete(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -282,35 +282,6 @@
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 删除按钮 */
|
||||
.xx-asset-delete {
|
||||
position: absolute;
|
||||
bottom: var(--space-sm, 8px);
|
||||
right: var(--space-sm, 8px);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: var(--transition-all, all 0.2s ease);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.xx-asset-card:hover .xx-asset-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.xx-asset-delete:hover {
|
||||
background: rgba(255, 77, 79, 0.85);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 选中态 */
|
||||
.xx-asset-card-selected {
|
||||
border-color: var(--primary-color) !important;
|
||||
|
||||
@@ -298,13 +298,6 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
const handleModeChange = (mode: TemplateMode) => {
|
||||
setCurrentMode(mode);
|
||||
// 切换纯单类型模式时,自动转换所有已有片段的类型
|
||||
if (mode === "voice_over") {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const })));
|
||||
} else if (mode === "pip") {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const })));
|
||||
}
|
||||
// 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型
|
||||
};
|
||||
|
||||
const handleClipSelect = (clipId: string) => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
@@ -55,34 +54,18 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
right: 0,
|
||||
});
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
currentMode === "voice_over"
|
||||
? ["voice"]
|
||||
: currentMode === "pip"
|
||||
? ["pip"]
|
||||
: ["voice", "pip"], // voice_pip / one_take / 默认
|
||||
[currentMode],
|
||||
);
|
||||
|
||||
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice";
|
||||
if (currentMode === "pip") return "pip";
|
||||
return "voice";
|
||||
}, [currentMode]);
|
||||
|
||||
/* ── "+" 卡片:类型+时长选择状态 ── */
|
||||
const [addType, setAddType] = useState<ClipType>(defaultAddType);
|
||||
const [addType, setAddType] = useState<ClipType>("voice");
|
||||
const [addDuration, setAddDuration] = useState<number>(5);
|
||||
|
||||
/* ── 模式切换时自动同步默认添加类型 ── */
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(addType)) {
|
||||
setAddType(defaultAddType);
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType]);
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] =
|
||||
currentMode === "voice_over"
|
||||
? ["voice"]
|
||||
: currentMode === "pip"
|
||||
? ["pip"]
|
||||
: ["voice", "pip"]; // voice_pip 或默认
|
||||
|
||||
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
|
||||
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
|
||||
const GAP = 6; // 面板与"+"卡片的间距
|
||||
@@ -109,14 +92,6 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showAddPicker) {
|
||||
// 打开面板时,默认选中当前模式下的第一个可用类型
|
||||
const defaultType =
|
||||
currentMode === "pip"
|
||||
? "pip"
|
||||
: currentMode === "voice_over"
|
||||
? "voice"
|
||||
: "voice";
|
||||
setAddType(defaultType);
|
||||
updatePickerPosition();
|
||||
}
|
||||
setShowAddPicker((v) => !v);
|
||||
|
||||
@@ -25,11 +25,7 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets";
|
||||
import {
|
||||
createEditPlan,
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
} from "@/api/editPlans";
|
||||
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
|
||||
import { getEditingTemplates } from "@/api/editingPlanner";
|
||||
import { getTitles } from "@/api/titles";
|
||||
import apiClient from "@/api/client";
|
||||
@@ -128,9 +124,9 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 标题 ── */
|
||||
const [title, setTitle] = useState("");
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryKey: ["generate-titles"],
|
||||
queryFn: () => getTitles(),
|
||||
staleTime: 30_000,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
|
||||
useEffect(() => {
|
||||
@@ -518,9 +514,6 @@ const GeneratePage: React.FC = () => {
|
||||
source_edit_plan_id: editPlanId || undefined,
|
||||
});
|
||||
|
||||
// 后端要求计划处于 editing 状态才能触发渲染,自动转换状态
|
||||
await updateEditPlan(plan.id, { status: "editing" });
|
||||
|
||||
await generateEditPlan(plan.id);
|
||||
|
||||
const poll = async () => {
|
||||
@@ -540,8 +533,7 @@ const GeneratePage: React.FC = () => {
|
||||
if (data.plan_status === "failed") {
|
||||
setGenerating(false);
|
||||
// 提取后端返回的错误详情,便于排查
|
||||
// 注意:后端返回的 error_message/error/message 可能是对象而非字符串
|
||||
const rawMsg =
|
||||
const errorMsg =
|
||||
data.error_message ||
|
||||
data.error ||
|
||||
data.message ||
|
||||
@@ -549,21 +541,6 @@ const GeneratePage: React.FC = () => {
|
||||
(c: { status: string }) => c.status === "failed",
|
||||
)?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试";
|
||||
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构)
|
||||
const safeExtract = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
if (obj.message && typeof obj.message === "object")
|
||||
return safeExtract(obj.message);
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return String(val ?? "");
|
||||
};
|
||||
const errorMsg = safeExtract(rawMsg);
|
||||
console.error("[生成失败] planId:", plan.id, "响应:", data);
|
||||
setGenerateError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
@@ -601,36 +578,19 @@ const GeneratePage: React.FC = () => {
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: {
|
||||
message?: string | object;
|
||||
error?: string | object;
|
||||
detail?: string | object;
|
||||
msg?: string | object;
|
||||
message?: string;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
msg?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
// 安全提取错误消息:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
|
||||
const extractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
// 嵌套对象:递归提取
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return extractString(obj.message);
|
||||
if (typeof obj.msg === "object" && obj.msg !== null)
|
||||
return extractString(obj.msg);
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const backendMsg =
|
||||
extractString(axiosErr.response?.data?.message) ||
|
||||
extractString(axiosErr.response?.data?.error) ||
|
||||
extractString(axiosErr.response?.data?.detail) ||
|
||||
extractString(axiosErr.response?.data?.msg) ||
|
||||
axiosErr.response?.data?.message ||
|
||||
axiosErr.response?.data?.error ||
|
||||
axiosErr.response?.data?.detail ||
|
||||
axiosErr.response?.data?.msg ||
|
||||
axiosErr.message ||
|
||||
"";
|
||||
console.error(
|
||||
@@ -639,71 +599,9 @@ const GeneratePage: React.FC = () => {
|
||||
"完整错误:",
|
||||
axiosErr,
|
||||
);
|
||||
// 确保 errorMsg 一定是字符串(后端可能返回 {code, message} 嵌套对象)
|
||||
const safeExtractErr = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
// 嵌套对象:递归提取
|
||||
if (typeof obj.message === "object")
|
||||
return safeExtractErr(obj.message);
|
||||
return JSON.stringify(val);
|
||||
}
|
||||
return String(val ?? "");
|
||||
};
|
||||
const rawError = safeExtractErr(backendMsg);
|
||||
// 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念)
|
||||
const translateError = (msg: string): string => {
|
||||
if (!msg) return "生成失败,请检查网络后重试或联系管理员";
|
||||
// 状态机相关错误
|
||||
if (
|
||||
msg.includes("editing") ||
|
||||
msg.includes("draft") ||
|
||||
msg.includes("状态")
|
||||
) {
|
||||
return "正在准备生成,请稍候再试";
|
||||
}
|
||||
// 参数校验错误
|
||||
if (
|
||||
msg.includes("template_id") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("不存在")
|
||||
) {
|
||||
return "所选模板或素材不可用,请重新选择";
|
||||
}
|
||||
if (
|
||||
msg.includes("asset") &&
|
||||
(msg.includes("not found") || msg.includes("missing"))
|
||||
) {
|
||||
return "素材数据异常,请返回素材库重新检查";
|
||||
}
|
||||
// 网络/超时
|
||||
if (
|
||||
msg.includes("timeout") ||
|
||||
msg.includes("network") ||
|
||||
msg.includes("ECONN")
|
||||
) {
|
||||
return "网络连接超时,请检查网络后重试";
|
||||
}
|
||||
// 配额/限制
|
||||
if (
|
||||
msg.includes("quota") ||
|
||||
msg.includes("limit") ||
|
||||
msg.includes("exceed")
|
||||
) {
|
||||
return "已达到生成次数上限,请稍后再试或联系客服";
|
||||
}
|
||||
// 兜底:返回原始消息(如果已经是中文人话)或默认提示
|
||||
if (msg.length > 0 && msg.length < 100 && !msg.includes("{"))
|
||||
return msg;
|
||||
return "生成失败,请稍后重试或联系管理员";
|
||||
};
|
||||
const finalMsg = translateError(rawError);
|
||||
setGenerateError(finalMsg);
|
||||
message.error(finalMsg);
|
||||
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
|
||||
setGenerateError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
}
|
||||
}, [
|
||||
title,
|
||||
@@ -1604,9 +1502,7 @@ const GeneratePage: React.FC = () => {
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{typeof generateError === "string"
|
||||
? generateError
|
||||
: JSON.stringify(generateError)}
|
||||
{generateError}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* 标题库页面 — V21 设计系统
|
||||
* 两栏布局:左侧分类列表(220px)+ 右侧标题卡片网格(3列)
|
||||
* 支持:标题卡片展示、AI 生成标题、复制/编辑/删除、收藏、分类筛选、搜索
|
||||
* 对接后端真实 API(GET/POST/PUT/DELETE /titles)
|
||||
* 使用 mock 数据,后端 API 对接暂不要求
|
||||
*/
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Modal as AntModal, message, Popconfirm } from "antd";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
@@ -20,13 +19,6 @@ import {
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import {
|
||||
getTitles,
|
||||
createTitle,
|
||||
updateTitle,
|
||||
deleteTitle,
|
||||
type TitleItem,
|
||||
} from "@/api/titles";
|
||||
import "./titles.css";
|
||||
|
||||
/* ============================================================
|
||||
@@ -64,16 +56,143 @@ const MOCK_CATEGORIES: CategoryItem[] = [
|
||||
{ id: "cat-5", name: "教育学习", count: 2 },
|
||||
];
|
||||
|
||||
/** 后端 TitleItem → 前端 TitleData 映射 */
|
||||
const toTitleData = (item: TitleItem): TitleData => ({
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
type: (item.category as TitleType) || "normal",
|
||||
industry: "general",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: item.created_at?.slice(0, 10) || "",
|
||||
});
|
||||
const MOCK_TITLES: TitleData[] = [
|
||||
{
|
||||
id: "t-1",
|
||||
content: "这家隐藏在巷子里的小店,味道绝了!",
|
||||
type: "hot",
|
||||
industry: "food",
|
||||
usageCount: 128,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-28",
|
||||
},
|
||||
{
|
||||
id: "t-2",
|
||||
content: "2026 年最值得入手的 5 款蓝牙耳机",
|
||||
type: "hot",
|
||||
industry: "tech",
|
||||
usageCount: 96,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-27",
|
||||
},
|
||||
{
|
||||
id: "t-3",
|
||||
content: "周末在家做了一道妈妈的味道",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 42,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-26",
|
||||
},
|
||||
{
|
||||
id: "t-4",
|
||||
content: "用 AI 帮我写了一周的小红书文案,效果惊人",
|
||||
type: "hot",
|
||||
industry: "tech",
|
||||
usageCount: 215,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-25",
|
||||
},
|
||||
{
|
||||
id: "t-5",
|
||||
content: "今天穿了一套被路人要链接的衣服",
|
||||
type: "creative",
|
||||
industry: "beauty",
|
||||
usageCount: 67,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-24",
|
||||
},
|
||||
{
|
||||
id: "t-6",
|
||||
content: "分享我的早起 5 点俱乐部 30 天打卡体验",
|
||||
type: "normal",
|
||||
industry: "education",
|
||||
usageCount: 38,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-23",
|
||||
},
|
||||
{
|
||||
id: "t-7",
|
||||
content: "这个平价面霜居然比大牌还好用?",
|
||||
type: "hot",
|
||||
industry: "beauty",
|
||||
usageCount: 183,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-22",
|
||||
},
|
||||
{
|
||||
id: "t-8",
|
||||
content: "一个人的旅行也可以很精彩",
|
||||
type: "normal",
|
||||
industry: "travel",
|
||||
usageCount: 55,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-21",
|
||||
},
|
||||
{
|
||||
id: "t-9",
|
||||
content: "考研上岸!我的备考时间管理方法全公开",
|
||||
type: "hot",
|
||||
industry: "education",
|
||||
usageCount: 147,
|
||||
isFavorited: true,
|
||||
createdAt: "2026-06-20",
|
||||
},
|
||||
{
|
||||
id: "t-10",
|
||||
content: "把旧 T 恤改造成时尚单品,零成本!",
|
||||
type: "creative",
|
||||
industry: "beauty",
|
||||
usageCount: 29,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-19",
|
||||
},
|
||||
{
|
||||
id: "t-11",
|
||||
content: "这家咖啡馆的氛围感也太好了吧",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 74,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-18",
|
||||
},
|
||||
{
|
||||
id: "t-12",
|
||||
content: "手机摄影技巧:拍出电影感画面",
|
||||
type: "creative",
|
||||
industry: "tech",
|
||||
usageCount: 61,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-17",
|
||||
},
|
||||
{
|
||||
id: "t-13",
|
||||
content: "带娃旅行必备清单,少带一样都崩溃",
|
||||
type: "hot",
|
||||
industry: "travel",
|
||||
usageCount: 109,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-16",
|
||||
},
|
||||
{
|
||||
id: "t-14",
|
||||
content: "30 天学会一门新语言?我的实验记录",
|
||||
type: "creative",
|
||||
industry: "education",
|
||||
usageCount: 33,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-15",
|
||||
},
|
||||
{
|
||||
id: "t-15",
|
||||
content: "今天做了一道让全家惊艳的菜",
|
||||
type: "normal",
|
||||
industry: "food",
|
||||
usageCount: 48,
|
||||
isFavorited: false,
|
||||
createdAt: "2026-06-14",
|
||||
},
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
@@ -248,48 +367,12 @@ const TitleCard: React.FC<{
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const TitleLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* 分类数据 */
|
||||
const [categories, setCategories] = useState<CategoryItem[]>(MOCK_CATEGORIES);
|
||||
const [activeCatId, setActiveCatId] = useState<string>(MOCK_CATEGORIES[0].id);
|
||||
|
||||
/* 标题数据 — 真实 API */
|
||||
const { data: apiTitles = [] } = useQuery({
|
||||
queryKey: ["titles"],
|
||||
queryFn: getTitles,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const titles: TitleData[] = useMemo(
|
||||
() => apiTitles.map(toTitleData),
|
||||
[apiTitles],
|
||||
);
|
||||
|
||||
/* CRUD mutations */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => createTitle({ content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] });
|
||||
},
|
||||
onError: () => message.error("创建标题失败"),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, content }: { id: string; content: string }) =>
|
||||
updateTitle(id, { content }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] });
|
||||
},
|
||||
onError: () => message.error("更新标题失败"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteTitle(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["titles"] });
|
||||
},
|
||||
onError: () => message.error("删除标题失败"),
|
||||
});
|
||||
/* 标题数据 */
|
||||
const [titles, setTitles] = useState<TitleData[]>(MOCK_TITLES);
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
@@ -380,9 +463,13 @@ const TitleLibrary: React.FC = () => {
|
||||
searchText,
|
||||
]);
|
||||
|
||||
/* 收藏切换(暂不支持,待后端 API) */
|
||||
const handleToggleFavorite = useCallback((_id: string) => {
|
||||
message.info("收藏功能即将上线");
|
||||
/* 收藏切换 */
|
||||
const handleToggleFavorite = useCallback((id: string) => {
|
||||
setTitles((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id ? { ...t, isFavorited: !t.isFavorited } : t,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/* 复制 */
|
||||
@@ -406,13 +493,15 @@ const TitleLibrary: React.FC = () => {
|
||||
message.warning("标题内容不能为空");
|
||||
return;
|
||||
}
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, content: editText.trim() });
|
||||
}
|
||||
setTitles((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === editingId ? { ...t, content: editText.trim() } : t,
|
||||
),
|
||||
);
|
||||
setEditingId(null);
|
||||
setEditText("");
|
||||
message.success("标题已更新");
|
||||
}, [editingId, editText, updateMutation]);
|
||||
}, [editingId, editText]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
@@ -420,13 +509,10 @@ const TitleLibrary: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
deleteMutation.mutate(id);
|
||||
message.success("标题已删除");
|
||||
},
|
||||
[deleteMutation],
|
||||
);
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
setTitles((prev) => prev.filter((t) => t.id !== id));
|
||||
message.success("标题已删除");
|
||||
}, []);
|
||||
|
||||
/* 新建分类 */
|
||||
const handleCreateCategory = () => {
|
||||
@@ -461,14 +547,20 @@ const TitleLibrary: React.FC = () => {
|
||||
message.warning("请输入标题内容");
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(newTitleContent.trim(), {
|
||||
onSuccess: () => {
|
||||
setCreateTitleModalOpen(false);
|
||||
setNewTitleContent("");
|
||||
setNewTitleType("normal");
|
||||
message.success("标题创建成功");
|
||||
},
|
||||
});
|
||||
const title: TitleData = {
|
||||
id: `t-${Date.now()}`,
|
||||
content: newTitleContent.trim(),
|
||||
type: newTitleType,
|
||||
industry: "general",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
setTitles((prev) => [title, ...prev]);
|
||||
setCreateTitleModalOpen(false);
|
||||
setNewTitleContent("");
|
||||
setNewTitleType("normal");
|
||||
message.success("标题创建成功");
|
||||
};
|
||||
|
||||
/* AI 生成标题 */
|
||||
@@ -497,11 +589,17 @@ const TitleLibrary: React.FC = () => {
|
||||
|
||||
/* 采纳 AI 生成的标题 */
|
||||
const handleAdoptAITitle = (text: string) => {
|
||||
createMutation.mutate(text, {
|
||||
onSuccess: () => {
|
||||
message.success("标题已采纳并添加到标题库");
|
||||
},
|
||||
});
|
||||
const title: TitleData = {
|
||||
id: `t-${Date.now()}`,
|
||||
content: text,
|
||||
type: "creative",
|
||||
industry: "general",
|
||||
usageCount: 0,
|
||||
isFavorited: false,
|
||||
createdAt: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
setTitles((prev) => [title, ...prev]);
|
||||
message.success("标题已采纳并添加到标题库");
|
||||
};
|
||||
|
||||
/* 复制 AI 生成的标题 */
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
ReloadOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Modal, Upload, message } from "antd";
|
||||
import { Button, Input, Select, Tooltip } from "@/components/ui";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import {
|
||||
@@ -45,12 +44,6 @@ import {
|
||||
toVoiceClone,
|
||||
type VoiceClone,
|
||||
} from "@/api/voiceClone";
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
|
||||
import {
|
||||
uploadAssetDirect,
|
||||
getAssetLibraries,
|
||||
createAsset,
|
||||
} from "@/api/assets";
|
||||
import CloneModal from "@/components/voice/CloneModal";
|
||||
import "./voices.css";
|
||||
|
||||
@@ -600,41 +593,6 @@ const CloneCardSkeleton: React.FC = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── 辅助函数 ─────────────────────────────────────────── */
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getAudioDuration = (file: File): Promise<number> =>
|
||||
new Promise((resolve) => {
|
||||
const audio = new Audio();
|
||||
const url = URL.createObjectURL(file);
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
resolve(audio.duration);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
audio.addEventListener("error", () => {
|
||||
resolve(0);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
audio.src = url;
|
||||
});
|
||||
|
||||
const buildVoiceMetadata = (data: {
|
||||
gender?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
}): Record<string, unknown> => {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (data.gender) metadata.gender = data.gender;
|
||||
if (data.description) metadata.description = data.description;
|
||||
if (data.duration) metadata.duration = Math.round(data.duration);
|
||||
return metadata;
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
@@ -655,27 +613,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false);
|
||||
|
||||
/* ── 上传音频弹窗状态 ── */
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [uploadName, setUploadName] = useState("");
|
||||
const [uploadGender, setUploadGender] = useState<VoiceGender>("female");
|
||||
const [uploadDesc, setUploadDesc] = useState("");
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
|
||||
/* ── AI 配音弹窗状态 ── */
|
||||
const [ttsOpen, setTtsOpen] = useState(false);
|
||||
const [ttsText, setTtsText] = useState("");
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0);
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
|
||||
const [ttsStatus, setTtsStatus] = useState<
|
||||
"idle" | "synthesizing" | "done" | "error"
|
||||
>("idle");
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
|
||||
const [ttsError, setTtsError] = useState<string | null>(null);
|
||||
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast["type"]) => {
|
||||
const id = ++toastIdSeq;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
@@ -708,130 +645,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 上传音频 mutation ── */
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
file: File;
|
||||
name: string;
|
||||
gender: VoiceGender;
|
||||
description: string;
|
||||
}) => {
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
/* 获取或创建默认配音素材库 */
|
||||
const libs = await queryClient.fetchQuery({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
});
|
||||
const lib = libs.find((l) => l.kind === "voice");
|
||||
if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建");
|
||||
|
||||
/* 直传文件 */
|
||||
const { storage_key } = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
});
|
||||
|
||||
/* 获取音频时长 */
|
||||
const duration = await getAudioDuration(data.file);
|
||||
|
||||
/* 创建素材记录 */
|
||||
await createAsset({
|
||||
library_id: lib.id,
|
||||
name: data.name,
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildVoiceMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
setUploadProgress(null);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
|
||||
setUploadOpen(false);
|
||||
setUploadFile(null);
|
||||
setUploadName("");
|
||||
setUploadDesc("");
|
||||
showToast("上传成功", "success");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message || "上传失败,请重试", "error");
|
||||
},
|
||||
});
|
||||
|
||||
/* ── TTS 合成 ── */
|
||||
const handleTtsSynthesize = useCallback(async () => {
|
||||
if (!ttsText.trim()) {
|
||||
message.warning("请输入要合成的文本");
|
||||
return;
|
||||
}
|
||||
setTtsError(null);
|
||||
setTtsStatus("synthesizing");
|
||||
setTtsAudioUrl(null);
|
||||
setTtsJobId(null);
|
||||
try {
|
||||
const resp = await synthesizeSpeech({
|
||||
text: ttsText.trim(),
|
||||
voice_id: ttsVoiceId || undefined,
|
||||
speed: ttsSpeed,
|
||||
});
|
||||
setTtsJobId(resp.job_id);
|
||||
/* 轮询状态 */
|
||||
ttsTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await getTTSJobStatus(resp.job_id);
|
||||
if (job.status === "completed") {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("done");
|
||||
setTtsAudioUrl(job.output_audio_url);
|
||||
} else if (job.status === "failed") {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("error");
|
||||
setTtsError(job.error_message || "合成失败");
|
||||
}
|
||||
} catch {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("error");
|
||||
setTtsError("查询合成状态失败");
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "合成请求失败";
|
||||
setTtsStatus("error");
|
||||
setTtsError(msg);
|
||||
}
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed]);
|
||||
|
||||
/* ── TTS 保存到素材库 ── */
|
||||
const handleTtsSave = useCallback(async () => {
|
||||
if (!ttsJobId) return;
|
||||
try {
|
||||
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) });
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
|
||||
showToast("已保存到配音素材库", "success");
|
||||
setTtsOpen(false);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "保存失败";
|
||||
showToast(msg, "error");
|
||||
}
|
||||
}, [ttsJobId, ttsText, queryClient, showToast]);
|
||||
|
||||
/* ── TTS 定时器清理 ── */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */
|
||||
|
||||
/** 预置音色列表 */
|
||||
@@ -975,12 +788,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
|
||||
const pageActions = (
|
||||
<div className="xx-voices-actions">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<UploadOutlined />}>
|
||||
上传音频
|
||||
</Button>
|
||||
<Button
|
||||
@@ -991,12 +799,7 @@ const VoiceLibrary: React.FC = () => {
|
||||
>
|
||||
克隆音色
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => setTtsOpen(true)}
|
||||
>
|
||||
<Button buttonType="primary" buttonSize="sm" icon={<RobotOutlined />}>
|
||||
AI 配音
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1205,544 +1008,6 @@ const VoiceLibrary: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 上传音频弹窗 ── */}
|
||||
<Modal
|
||||
title="上传音频"
|
||||
open={uploadOpen}
|
||||
onCancel={() => {
|
||||
if (uploadProgress !== null) return; // 上传中不可关闭
|
||||
setUploadOpen(false);
|
||||
setUploadFile(null);
|
||||
setUploadName("");
|
||||
setUploadDesc("");
|
||||
}}
|
||||
footer={null}
|
||||
width={520}
|
||||
maskClosable={uploadProgress === null}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* 拖拽上传区 */}
|
||||
<Upload.Dragger
|
||||
accept="audio/*"
|
||||
maxCount={1}
|
||||
beforeUpload={(file) => {
|
||||
setUploadFile(file);
|
||||
if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, ""));
|
||||
return false;
|
||||
}}
|
||||
onRemove={() => {
|
||||
setUploadFile(null);
|
||||
setUploadProgress(null);
|
||||
}}
|
||||
showUploadList={false}
|
||||
disabled={uploadProgress !== null}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>
|
||||
点击或拖拽音频文件到此处
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 200MB
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
|
||||
{/* 已选文件信息 */}
|
||||
{uploadFile && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined
|
||||
style={{ fontSize: 18, color: "var(--primary-color)" }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{uploadFile.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
{formatFileSize(uploadFile.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传进度 */}
|
||||
{uploadProgress !== null && (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
>
|
||||
{uploadProgress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{uploadProgress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${uploadProgress}%`,
|
||||
background: "var(--primary-color)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
素材名称
|
||||
</div>
|
||||
<input
|
||||
value={uploadName}
|
||||
onChange={(e) => setUploadName(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
maxLength={100}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色性别
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["female", "male", "child"] as VoiceGender[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => setUploadGender(g)}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background:
|
||||
uploadGender === g
|
||||
? "var(--primary-soft)"
|
||||
: "transparent",
|
||||
color:
|
||||
uploadGender === g
|
||||
? "var(--primary-color)"
|
||||
: "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: uploadGender === g ? 600 : 400,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{g === "female" ? "女声" : g === "male" ? "男声" : "童声"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
音色描述(可选)
|
||||
</div>
|
||||
<textarea
|
||||
value={uploadDesc}
|
||||
onChange={(e) => setUploadDesc(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 10,
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadOpen(false);
|
||||
setUploadFile(null);
|
||||
setUploadName("");
|
||||
setUploadDesc("");
|
||||
}}
|
||||
disabled={uploadProgress !== null}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploadProgress !== null ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!uploadFile) {
|
||||
message.warning("请先选择音频文件");
|
||||
return;
|
||||
}
|
||||
if (!uploadName.trim()) {
|
||||
message.warning("请输入素材名称");
|
||||
return;
|
||||
}
|
||||
uploadMutation.mutate({
|
||||
file: uploadFile,
|
||||
name: uploadName.trim(),
|
||||
gender: uploadGender,
|
||||
description: uploadDesc.trim(),
|
||||
});
|
||||
}}
|
||||
disabled={
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor:
|
||||
!uploadFile || !uploadName.trim() || uploadProgress !== null
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
}}
|
||||
>
|
||||
{uploadProgress !== null ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ── AI 配音弹窗 ── */}
|
||||
<Modal
|
||||
title="AI 配音"
|
||||
open={ttsOpen}
|
||||
onCancel={() => {
|
||||
setTtsOpen(false);
|
||||
setTtsText("");
|
||||
setTtsVoiceId("");
|
||||
setTtsSpeed(1.0);
|
||||
setTtsStatus("idle");
|
||||
setTtsAudioUrl(null);
|
||||
setTtsError(null);
|
||||
setTtsJobId(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={560}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</div>
|
||||
<textarea
|
||||
value={ttsText}
|
||||
onChange={(e) => setTtsText(e.target.value)}
|
||||
placeholder="输入要配音的文本内容..."
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
textAlign: "right",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{ttsText.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</div>
|
||||
<select
|
||||
value={ttsVoiceId}
|
||||
onChange={(e) => setTtsVoiceId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
语速:{ttsSpeed.toFixed(1)}x
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
|
||||
style={{ width: "100%", accentColor: "var(--primary-color)" }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary)",
|
||||
}}
|
||||
>
|
||||
<span>0.5x</span>
|
||||
<span>1.0x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTtsSynthesize}
|
||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 0",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background:
|
||||
ttsStatus === "synthesizing" || !ttsText.trim()
|
||||
? "var(--text-tertiary)"
|
||||
: "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor:
|
||||
ttsStatus === "synthesizing" || !ttsText.trim()
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined />
|
||||
{ttsStatus === "synthesizing" ? "合成中..." : "开始合成"}
|
||||
</button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{ttsError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
background: "var(--error-soft, #fff2f0)",
|
||||
borderRadius: 8,
|
||||
color: "var(--error-color, #ff4d4f)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--success-color, #52c41a)",
|
||||
}}
|
||||
>
|
||||
✅ 合成完成
|
||||
</div>
|
||||
<audio controls src={ttsAudioUrl} style={{ width: "100%" }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTtsSave}
|
||||
style={{
|
||||
padding: "8px 0",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--primary-color)",
|
||||
background: "var(--primary-soft)",
|
||||
color: "var(--primary-color)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
保存到配音素材库
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Toast 提示 */}
|
||||
{toasts.length > 0 && (
|
||||
<div className="vc-toast-container">
|
||||
|
||||
@@ -215,8 +215,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
"""
|
||||
logger.info("开始渲染剪辑计划: plan_id=%s", plan_id)
|
||||
|
||||
generation_task_id = ""
|
||||
|
||||
for repos in _get_repos():
|
||||
plan_repo, clip_repo, gen_task_repo, db = repos
|
||||
|
||||
@@ -227,9 +225,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
logger.error("剪辑计划不存在: %s", plan_id)
|
||||
return {"status": "error", "message": f"计划不存在: {plan_id}"}
|
||||
|
||||
# 获取 generation_task_id(提前读取,确保 except 块可用)
|
||||
generation_task_id = plan.config.get("generation_task_id", "")
|
||||
|
||||
# 2. 加载片段列表(按 order 排序)
|
||||
clips = clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
|
||||
if not clips:
|
||||
@@ -238,6 +233,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
plan_repo.update(plan)
|
||||
return {"status": "error", "message": "没有可渲染的片段"}
|
||||
|
||||
# 获取 generation_task_id
|
||||
generation_task_id = plan.config.get("generation_task_id", "")
|
||||
|
||||
# 更新 GenerationTask 状态为 running
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
@@ -347,31 +345,15 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("渲染剪辑计划异常: %s", plan_id)
|
||||
# 尝试标记计划和 GenerationTask 为失败
|
||||
# 尝试标记计划为失败
|
||||
try:
|
||||
plan = plan_repo.get(plan_id)
|
||||
if plan and plan.status.value == "rendering":
|
||||
plan.mark_failed()
|
||||
plan_repo.update(plan)
|
||||
except Exception as e:
|
||||
logger.warning("标记计划失败时异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
|
||||
# 更新 GenerationTask 状态为 failed,前端轮询能看到失败状态
|
||||
try:
|
||||
if generation_task_id:
|
||||
gen_task = gen_task_repo.get(generation_task_id)
|
||||
if gen_task and gen_task.status.value != "failed":
|
||||
gen_task.status = "failed"
|
||||
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
|
||||
gen_task.completed_at = datetime.now(timezone.utc)
|
||||
gen_task_repo.update(gen_task)
|
||||
logger.info(
|
||||
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
|
||||
generation_task_id,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
|
||||
f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True
|
||||
)
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ============================================================
|
||||
|
||||
# 基础镜像:Python 3.12
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=0 \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
|
||||
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
|
||||
ARG NGINX_CONF=infra/docker/nginx.conf
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY apps/web/dist ./
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
|
||||
FROM docker.m.daocloud.io/library/node:20 AS builder
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
@@ -11,7 +11,7 @@ COPY apps/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Production stage with nginx
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
|
||||
FROM docker.m.daocloud.io/library/nginx:alpine AS runner
|
||||
ARG NGINX_CONF=infra/docker/nginx.conf
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY --from=builder /app/apps/web/dist ./
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ============================================================
|
||||
|
||||
# 基础镜像:Python 3.12 + ffmpeg
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
@@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libgl1 \
|
||||
libgl1-mesa-glx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 设置工作目录
|
||||
|
||||
@@ -16,9 +16,6 @@ class InMemoryAssetLibraryRepository:
|
||||
def get(self, library_id: str) -> AssetLibrary | None:
|
||||
return self._libraries.get(library_id)
|
||||
|
||||
def find_by_id(self, library_id: str) -> AssetLibrary | None:
|
||||
return self.get(library_id)
|
||||
|
||||
def find_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]:
|
||||
items = [library for library in self._libraries.values() if library.project_id == project_id]
|
||||
if kind is not None:
|
||||
|
||||
@@ -3,20 +3,11 @@ set -eu
|
||||
|
||||
VERSION="${1:-${RELEASE_VERSION:-}}"
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <version> [staging|production]"
|
||||
echo "Example: $0 v0.1.5 production"
|
||||
echo " $0 abc1234 staging"
|
||||
echo "Usage: $0 <version>"
|
||||
echo "Example: $0 v0.1.5"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 环境参数:staging 或 production(默认 production)
|
||||
BUILD_ENV="${2:-production}"
|
||||
case "$BUILD_ENV" in
|
||||
staging) NGINX_CONF_FILE="infra/docker/nginx-staging.conf" ;;
|
||||
*) NGINX_CONF_FILE="infra/docker/nginx-production.conf" ;;
|
||||
esac
|
||||
echo "Build environment: $BUILD_ENV → nginx config: $NGINX_CONF_FILE"
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
@@ -95,14 +86,14 @@ if [ "$USE_CACHE" -eq 1 ]; then
|
||||
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
|
||||
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
|
||||
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
|
||||
-t "$WEB_IMAGE" \
|
||||
--load \
|
||||
.
|
||||
else
|
||||
docker build --pull=false \
|
||||
-f infra/docker/web-artifact.Dockerfile \
|
||||
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
|
||||
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
|
||||
-t "$WEB_IMAGE" \
|
||||
.
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查指定commit的CI status状态。
|
||||
|
||||
用法: python3 check_ci_status.py <token> <repo> <sha> <context>
|
||||
返回: 打印状态 (success/failure/pending/error)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
token = sys.argv[1]
|
||||
repo = sys.argv[2]
|
||||
sha = sys.argv[3]
|
||||
target_context = sys.argv[4]
|
||||
|
||||
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/commits/{sha}/statuses?per_page=100"
|
||||
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
statuses = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# API返回按时间倒序,第一个就是最新的
|
||||
for s in statuses:
|
||||
if s.get("context") == target_context:
|
||||
print(s.get("status", "pending"))
|
||||
return
|
||||
|
||||
print("pending")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,249 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
数据库迁移破坏性变更安全检查
|
||||
|
||||
只检查 Alembic 迁移文件的 upgrade 函数中是否包含破坏性操作:
|
||||
- DROP TABLE
|
||||
- ALTER TABLE ... DROP COLUMN
|
||||
- 列类型变更(可能导致数据丢失)
|
||||
- NOT NULL 约束新增(无默认值时)
|
||||
- RENAME TABLE / RENAME COLUMN
|
||||
|
||||
忽略 downgrade 函数中的操作(那是回滚逻辑,正常的)。
|
||||
|
||||
使用方式:
|
||||
# 检查所有迁移(不推荐,会扫历史已执行的迁移)
|
||||
python3 scripts/check_migration_safety.py
|
||||
|
||||
# 只检查与目标分支相比新增的迁移(推荐用于CI)
|
||||
python3 scripts/check_migration_safety.py --diff-against origin/main
|
||||
|
||||
# 只检查指定版本之后的迁移
|
||||
python3 scripts/check_migration_safety.py --since 030_xxx
|
||||
|
||||
退出码:
|
||||
0 - 安全 / 只有非破坏性变更
|
||||
1 - 检测到高风险破坏性变更
|
||||
2 - 检测到中风险变更,需人工确认
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_VERSIONS_DIR = REPO_ROOT / "alembic" / "versions"
|
||||
|
||||
# 高风险模式:直接导致数据丢失(只在 upgrade 中检查)
|
||||
HIGH_RISK_PATTERNS = [
|
||||
(r"\bop\.drop_table\(", "op.drop_table() - 删除表,数据永久丢失"),
|
||||
(r"\bop\.drop_column\(", "op.drop_column() - 删除列,数据永久丢失"),
|
||||
]
|
||||
|
||||
# 中风险模式:可能导致数据丢失或兼容性问题
|
||||
MEDIUM_RISK_PATTERNS = [
|
||||
(r"op\.alter_column\([^)]*nullable\s*=\s*False", "新增 NOT NULL 约束 - 旧数据可能为空导致迁移失败"),
|
||||
(r"op\.alter_column\([^)]*type_\s*=", "列类型变更 - 可能导致数据截断或转换失败"),
|
||||
(r"\bop\.rename_table\(", "op.rename_table() - 重命名表,可能导致依赖该表的代码报错"),
|
||||
(r"\bop\.rename_column\(", "op.rename_column() - 重命名列,可能导致依赖该列的代码报错"),
|
||||
(r"\bop\.drop_index\(", "op.drop_index() - 删除索引,可能影响查询性能"),
|
||||
(r"\bop\.drop_constraint\(", "op.drop_constraint() - 删除约束,可能影响数据完整性"),
|
||||
]
|
||||
|
||||
# 安全模式:这些是安全的新增操作
|
||||
SAFE_PATTERNS = [
|
||||
(r"\bop\.create_table\(", "新建表"),
|
||||
(r"\bop\.add_column\(", "新增列"),
|
||||
(r"\bop\.create_index\(", "新建索引"),
|
||||
(r"\bop\.create_unique_constraint\(", "新建唯一约束"),
|
||||
(r"\bop\.create_foreign_key\(", "新建外键约束"),
|
||||
]
|
||||
|
||||
|
||||
def extract_upgrade_content(content: str) -> str:
|
||||
"""
|
||||
从迁移文件中提取 upgrade 函数的内容。
|
||||
只检查 upgrade 中的操作,忽略 downgrade。
|
||||
"""
|
||||
upgrade_match = re.search(r"def upgrade\b[^:]*:", content)
|
||||
if not upgrade_match:
|
||||
return ""
|
||||
|
||||
upgrade_start = upgrade_match.end()
|
||||
|
||||
# 找到下一个顶层 def(通常是 def downgrade)作为结束位置
|
||||
rest = content[upgrade_start:]
|
||||
downgrade_match = re.search(r"\n\ndef\s+\w+\b", rest)
|
||||
if downgrade_match:
|
||||
upgrade_end = upgrade_start + downgrade_match.start()
|
||||
else:
|
||||
upgrade_end = len(content)
|
||||
|
||||
return content[upgrade_start:upgrade_end]
|
||||
|
||||
|
||||
def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
|
||||
"""
|
||||
通过 git diff 对比目标分支/commit,找出 alembic/versions/ 下新增的迁移文件。
|
||||
只包含新增文件(A状态),不包含修改或删除的文件。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "--diff-filter=A", diff_target, "HEAD", "--", "alembic/versions/"],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
files = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
||||
return [REPO_ROOT / f for f in files]
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
|
||||
print(f" 降级为检查所有迁移文件")
|
||||
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
|
||||
|
||||
|
||||
def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]:
|
||||
"""
|
||||
找出需要检查的迁移文件。
|
||||
优先级:diff_against > since_revision > 全部
|
||||
"""
|
||||
if diff_against:
|
||||
return get_new_migrations_via_diff(diff_against)
|
||||
|
||||
all_migrations = sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
|
||||
if not since_revision:
|
||||
return all_migrations
|
||||
|
||||
result = []
|
||||
found = False
|
||||
for m in all_migrations:
|
||||
if since_revision in m.name or since_revision in m.stem:
|
||||
found = True
|
||||
continue
|
||||
if found:
|
||||
result.append(m)
|
||||
|
||||
return result if found else all_migrations
|
||||
|
||||
|
||||
def analyze_migration(file_path: Path) -> Tuple[List[str], List[str], List[str]]:
|
||||
"""分析单个迁移文件 upgrade 部分的风险等级"""
|
||||
content = file_path.read_text()
|
||||
upgrade_content = extract_upgrade_content(content)
|
||||
|
||||
if not upgrade_content:
|
||||
return [], [], [f"{file_path.name}: 未找到 upgrade 函数"]
|
||||
|
||||
high_risks = []
|
||||
medium_risks = []
|
||||
safes = []
|
||||
|
||||
for pattern, desc in HIGH_RISK_PATTERNS:
|
||||
if re.search(pattern, upgrade_content):
|
||||
high_risks.append(f"{file_path.name}: {desc}")
|
||||
|
||||
for pattern, desc in MEDIUM_RISK_PATTERNS:
|
||||
if re.search(pattern, upgrade_content):
|
||||
medium_risks.append(f"{file_path.name}: {desc}")
|
||||
|
||||
for pattern, desc in SAFE_PATTERNS:
|
||||
if re.search(pattern, upgrade_content):
|
||||
safes.append(f"{file_path.name}: {desc}")
|
||||
|
||||
return high_risks, medium_risks, safes
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument(
|
||||
"--since",
|
||||
default=os.getenv("MIGRATION_SINCE_REVISION"),
|
||||
help="只检查指定版本之后的迁移(如:030_xxx),不传则检查所有迁移",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diff-against",
|
||||
default=os.getenv("MIGRATION_DIFF_AGAINST"),
|
||||
help="对比指定分支/commit,只检查新增的迁移文件(推荐用于CI,如 origin/main)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warn-only",
|
||||
action="store_true",
|
||||
help="只警告不失败(用于非强制门禁场景)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-medium-risk",
|
||||
action="store_true",
|
||||
help="允许中风险变更(只拦截高风险)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
migrations = find_new_migrations(args.since, args.diff_against)
|
||||
|
||||
if not migrations:
|
||||
print("✅ 未找到需要检查的新增迁移文件,跳过")
|
||||
return 0
|
||||
|
||||
print(f"🔍 正在检查 {len(migrations)} 个迁移文件的 upgrade 操作...")
|
||||
if args.diff_against:
|
||||
print(f" (对比基准:{args.diff_against},仅检查新增迁移)")
|
||||
print()
|
||||
|
||||
all_high = []
|
||||
all_medium = []
|
||||
all_safe = []
|
||||
|
||||
for m in migrations:
|
||||
high, medium, safe = analyze_migration(m)
|
||||
all_high.extend(high)
|
||||
all_medium.extend(medium)
|
||||
all_safe.extend(safe)
|
||||
|
||||
if all_safe:
|
||||
print("✅ 安全变更:")
|
||||
for s in all_safe:
|
||||
print(f" - {s}")
|
||||
print()
|
||||
|
||||
if all_medium:
|
||||
print("⚠️ 中风险变更(需人工确认):")
|
||||
for m_item in all_medium:
|
||||
print(f" - {m_item}")
|
||||
print()
|
||||
|
||||
if all_high:
|
||||
print("❌ 高风险破坏性变更(禁止自动部署):")
|
||||
for h in all_high:
|
||||
print(f" - {h}")
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print(f"检查结果:{len(all_safe)} 项安全 / {len(all_medium)} 项中风险 / {len(all_high)} 项高风险")
|
||||
print()
|
||||
|
||||
if all_high:
|
||||
print("❌ 检测到高风险破坏性变更,CI 检查失败!")
|
||||
print(" 如果确认这是预期操作,请在 MR/PR 中说明原因并获得审批。")
|
||||
if args.warn_only:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
if all_medium and not args.allow_medium_risk:
|
||||
print("⚠️ 检测到中风险变更,请人工确认后再部署。")
|
||||
if args.warn_only:
|
||||
return 0
|
||||
print("(如需仅拦截高风险,可使用 --allow-medium-risk 参数)")
|
||||
return 2
|
||||
|
||||
print("✅ 未检测到破坏性变更")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查PR是否有至少N个APPROVED审批。
|
||||
|
||||
用法: python3 check_pr_approval.py <token> <repo> <pr_number> <min_approval>
|
||||
返回: 打印 "approved" 或 "pending"
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
token = sys.argv[1]
|
||||
repo = sys.argv[2]
|
||||
pr_number = sys.argv[3]
|
||||
min_approval = int(sys.argv[4])
|
||||
|
||||
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/pulls/{pr_number}/reviews"
|
||||
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
reviews = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# 统计APPROVED的人数(去重,同一人多次审批只算一次)
|
||||
approvers = set()
|
||||
for r in reviews:
|
||||
if r.get("state") == "APPROVED":
|
||||
approvers.add(r.get("user", {}).get("login", ""))
|
||||
|
||||
if len(approvers) >= min_approval:
|
||||
print(f"approved ({len(approvers)})")
|
||||
else:
|
||||
print(f"pending ({len(approvers)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR自动扫描器:扫描所有open PR,对CI全绿的进行自动审批/合并
|
||||
作为短作业模式的兜底机制,每5分钟运行一次
|
||||
|
||||
新增:AI审查联动 - AI代码审查发现严重问题时,不自动审批
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def api_request(token, repo, endpoint, method="GET", data=None):
|
||||
"""Gitea API请求"""
|
||||
url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/{endpoint}"
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
# 跳过SSL验证
|
||||
import ssl
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=ctx)
|
||||
return json.loads(resp.read().decode()), resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
|
||||
|
||||
|
||||
def get_open_prs(token, repo, base="develop"):
|
||||
"""获取所有open的PR"""
|
||||
prs = []
|
||||
page = 1
|
||||
while True:
|
||||
data, code = api_request(token, repo, f"pulls?state=open&base={base}&sort=recentupdate&per_page=50&page={page}")
|
||||
if code != 200 or not isinstance(data, list) or len(data) == 0:
|
||||
break
|
||||
prs.extend(data)
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
return prs
|
||||
|
||||
|
||||
def get_commit_status(token, repo, sha):
|
||||
"""获取commit的CI状态汇总"""
|
||||
data, code = api_request(token, repo, f"commits/{sha}/status")
|
||||
if code != 200:
|
||||
return {}, "error"
|
||||
return data, data.get("state", "unknown")
|
||||
|
||||
|
||||
def check_required_contexts(token, repo, sha, contexts):
|
||||
"""检查指定的context是否都通过"""
|
||||
data, _ = get_commit_status(token, repo, sha)
|
||||
statuses = {s["context"]: s["status"] for s in data.get("statuses", [])}
|
||||
|
||||
all_success = True
|
||||
any_pending = False
|
||||
any_failed = False
|
||||
|
||||
for ctx in contexts:
|
||||
state = statuses.get(ctx, "pending")
|
||||
if state != "success":
|
||||
all_success = False
|
||||
if state == "pending":
|
||||
any_pending = True
|
||||
if state in ("failure", "error"):
|
||||
any_failed = True
|
||||
|
||||
return all_success, any_pending, any_failed, statuses
|
||||
|
||||
|
||||
def get_pr_files(token, repo, pr_number):
|
||||
"""获取PR变更文件"""
|
||||
files = []
|
||||
page = 1
|
||||
while True:
|
||||
data, code = api_request(token, repo, f"pulls/{pr_number}/files?per_page=300&page={page}")
|
||||
if code != 200 or not isinstance(data, list) or len(data) == 0:
|
||||
break
|
||||
files.extend(data)
|
||||
if len(data) < 300:
|
||||
break
|
||||
page += 1
|
||||
return [f["filename"] for f in files]
|
||||
|
||||
|
||||
def is_frontend_only(files):
|
||||
"""判断是否纯前端改动"""
|
||||
if not files:
|
||||
return False
|
||||
frontend_count = sum(1 for f in files if f.startswith("apps/web/"))
|
||||
backend_count = len(files) - frontend_count
|
||||
return backend_count == 0 and frontend_count > 0
|
||||
|
||||
|
||||
def has_approval(token, repo, pr_number):
|
||||
"""检查PR是否已有审批"""
|
||||
reviews, code = api_request(token, repo, f"pulls/{pr_number}/reviews")
|
||||
if code != 200:
|
||||
return False
|
||||
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
|
||||
|
||||
|
||||
def get_ai_review_result(token, repo, pr_number):
|
||||
"""
|
||||
检查AI代码审查结果,返回 (has_critical, review_body)
|
||||
has_critical: 是否有严重问题(需修改的问题 > 0)
|
||||
review_body: 最新的AI审查评论文本
|
||||
"""
|
||||
# AI审查评论标记
|
||||
AI_REVIEW_MARKER = "AI_CODE_REVIEW_AUTO_COMMENT"
|
||||
|
||||
comments, code = api_request(token, repo, f"issues/{pr_number}/comments")
|
||||
if code != 200:
|
||||
return False, None
|
||||
|
||||
# 找最新的AI审查评论
|
||||
ai_comments = [c for c in comments if isinstance(c, dict) and AI_REVIEW_MARKER in c.get("body", "")]
|
||||
|
||||
if not ai_comments:
|
||||
return False, None
|
||||
|
||||
# 按时间排序,取最新的
|
||||
latest = max(ai_comments, key=lambda c: c.get("created_at", ""))
|
||||
body = latest.get("body", "")
|
||||
|
||||
# 解析严重问题数量
|
||||
# 匹配 "严重问题数量:X 个" 或 "需修改的问题(严重)" 下的列表
|
||||
critical_count = 0
|
||||
|
||||
# 方式1:直接匹配数字
|
||||
match = re.search(r"严重问题数量[::]\s*(\d+)\s*个", body)
|
||||
if match:
|
||||
critical_count = int(match.group(1))
|
||||
else:
|
||||
# 方式2:数 "需修改的问题" 章节下的条目数
|
||||
critical_section = re.search(
|
||||
r"###\s*[❌⚠️].*?(?:需修改|问题).*?\n(.*?)(?=\n###|\Z)",
|
||||
body,
|
||||
re.DOTALL,
|
||||
)
|
||||
if critical_section:
|
||||
section_text = critical_section.group(1)
|
||||
# 数编号条目 1. 2. 3.
|
||||
items = re.findall(r"^\d+\.\s+\*\*", section_text, re.MULTILINE)
|
||||
critical_count = len(items)
|
||||
|
||||
has_critical = critical_count > 0
|
||||
return has_critical, body
|
||||
|
||||
|
||||
def approve_pr(token, repo, pr_number, reason="CI全绿,自动审批通过。"):
|
||||
"""审批PR"""
|
||||
# 创建review
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews",
|
||||
method="POST",
|
||||
data={"event": "PENDING", "body": reason},
|
||||
)
|
||||
|
||||
if code not in (200, 201):
|
||||
return False, f"创建review失败: HTTP {code}"
|
||||
|
||||
review_id = data.get("id")
|
||||
if data.get("state") == "APPROVED":
|
||||
return True, "直接创建APPROVED成功"
|
||||
|
||||
if not review_id:
|
||||
return False, "未获取到review ID"
|
||||
|
||||
# submit为APPROVED
|
||||
data2, code2 = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}/events",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": reason},
|
||||
)
|
||||
|
||||
if code2 in (200, 201):
|
||||
return True, "审批提交成功"
|
||||
else:
|
||||
# 尝试另一个端点
|
||||
data3, code3 = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": reason},
|
||||
)
|
||||
if code3 in (200, 201):
|
||||
return True, "审批提交成功(备用端点)"
|
||||
return False, f"审批提交失败: HTTP {code2}/{code3}"
|
||||
|
||||
|
||||
def add_pr_label(token, repo, pr_number, label):
|
||||
"""给PR添加标签"""
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"issues/{pr_number}/labels",
|
||||
method="POST",
|
||||
data={"labels": [label]},
|
||||
)
|
||||
return code in (200, 201)
|
||||
|
||||
|
||||
def merge_pr(token, repo, pr_number):
|
||||
"""合并PR(squash merge)"""
|
||||
# 等待几秒让状态同步
|
||||
time.sleep(30)
|
||||
|
||||
# 检查PR状态
|
||||
pr_data, code = api_request(token, repo, f"pulls/{pr_number}")
|
||||
if code != 200:
|
||||
return False, f"获取PR状态失败: HTTP {code}"
|
||||
if pr_data.get("state") != "open":
|
||||
return False, f"PR状态不是open: {pr_data.get('state')}"
|
||||
|
||||
# 执行squash merge
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/merge",
|
||||
method="POST",
|
||||
data={
|
||||
"do": "squash",
|
||||
"merge_title_field": "",
|
||||
"merge_message_field": "",
|
||||
"delete_branch_after_merge": True,
|
||||
"force_merge": False,
|
||||
},
|
||||
)
|
||||
|
||||
if code == 200:
|
||||
return True, "合并成功"
|
||||
elif code == 405:
|
||||
return False, "合并返回405(门禁未满足或冲突)"
|
||||
else:
|
||||
return False, f"合并失败: HTTP {code}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PR自动扫描器")
|
||||
parser.add_argument("--token", required=True, help="Gitea API token")
|
||||
parser.add_argument("--repo", default="xiaoxia/xiaoxia-saas", help="仓库")
|
||||
parser.add_argument("--base", default="develop", help="目标分支")
|
||||
parser.add_argument("--approve", action="store_true", help="执行自动审批")
|
||||
parser.add_argument("--merge", action="store_true", help="执行自动合并")
|
||||
parser.add_argument("--dry-run", default="false", help="试运行模式")
|
||||
parser.add_argument("--max-prs", type=int, default=20, help="最多处理的PR数")
|
||||
parser.add_argument("--skip-ai-review", action="store_true", help="跳过AI审查检查(强制审批)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
dry_run = args.dry_run.lower() == "true"
|
||||
|
||||
# required contexts(与分支保护一致)
|
||||
REQUIRED_CONTEXTS_FULL = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)",
|
||||
]
|
||||
REQUIRED_CONTEXTS_APPROVE = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
FRONTEND_ONLY_CONTEXT = [
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
|
||||
# 获取所有open PR
|
||||
print(f"获取 {args.base} 分支的open PR...")
|
||||
prs = get_open_prs(args.token, args.repo, args.base)
|
||||
print(f"找到 {len(prs)} 个open PR")
|
||||
|
||||
approved_count = 0
|
||||
merged_count = 0
|
||||
skipped_count = 0
|
||||
ai_blocked_count = 0
|
||||
|
||||
for pr in prs[: args.max_prs]:
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
base_ref = pr.get("base", {}).get("re", "")
|
||||
|
||||
# 跳过draft
|
||||
if pr.get("draft"):
|
||||
print(f"\n⏭️ #{pr_num} {pr_title[:50]} - draft,跳过")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 跳过目标分支不对的
|
||||
if base_ref != args.base:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
print(f"\n--- #{pr_num} {pr_title[:60]} ---")
|
||||
|
||||
# 判断是否纯前端
|
||||
files = get_pr_files(args.token, args.repo, pr_num)
|
||||
frontend_only = is_frontend_only(files)
|
||||
|
||||
if frontend_only:
|
||||
approve_contexts = FRONTEND_ONLY_CONTEXT
|
||||
merge_contexts = FRONTEND_ONLY_CONTEXT
|
||||
print(f" 类型: 纯前端改动 ({len(files)}个文件)")
|
||||
else:
|
||||
approve_contexts = REQUIRED_CONTEXTS_APPROVE
|
||||
merge_contexts = REQUIRED_CONTEXTS_FULL
|
||||
print(f" 类型: 全栈/后端改动 ({len(files)}个文件)")
|
||||
|
||||
# 检查审批用的CI状态
|
||||
all_ok, pending, failed, _ = check_required_contexts(args.token, args.repo, head_sha, approve_contexts)
|
||||
|
||||
# === AI审查检查 ===
|
||||
ai_has_critical = False
|
||||
if not args.skip_ai_review and all_ok and not failed and args.approve:
|
||||
ai_has_critical, ai_body = get_ai_review_result(args.token, args.repo, pr_num)
|
||||
if ai_has_critical:
|
||||
print(" ⚠️ AI审查发现严重问题,阻止自动审批")
|
||||
ai_blocked_count += 1
|
||||
# 给PR打标签便于人工识别
|
||||
if not dry_run:
|
||||
add_pr_label(args.token, args.repo, pr_num, "ai-review/需修改")
|
||||
|
||||
# === 自动审批 ===
|
||||
if args.approve and all_ok and not failed and not ai_has_critical:
|
||||
if has_approval(args.token, args.repo, pr_num):
|
||||
print(" ✅ 已有审批,跳过")
|
||||
else:
|
||||
if dry_run:
|
||||
print(" 🎯 [DRY-RUN] 将自动审批")
|
||||
else:
|
||||
print(" 🎯 执行自动审批...")
|
||||
ok, msg = approve_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 审批成功: {msg}")
|
||||
approved_count += 1
|
||||
else:
|
||||
print(f" ❌ 审批失败: {msg}")
|
||||
elif ai_has_critical:
|
||||
print(" 🚫 AI审查阻止审批(人工可手动审批覆盖)")
|
||||
elif failed:
|
||||
print(" ❌ CI有失败项,跳过审批")
|
||||
elif pending:
|
||||
print(" ⏳ CI仍在运行,跳过")
|
||||
|
||||
# === 自动合并 ===
|
||||
if args.merge:
|
||||
# 检查合并用的CI状态
|
||||
merge_ok, merge_pending, merge_failed, _ = check_required_contexts(
|
||||
args.token, args.repo, head_sha, merge_contexts
|
||||
)
|
||||
|
||||
# 检查审批
|
||||
approved = has_approval(args.token, args.repo, pr_num)
|
||||
|
||||
if merge_ok and approved and not merge_failed:
|
||||
if dry_run:
|
||||
print(" 🎯 [DRY-RUN] 将自动合并")
|
||||
else:
|
||||
print(" 🎯 执行自动合并...")
|
||||
ok, msg = merge_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 合并成功: {msg}")
|
||||
merged_count += 1
|
||||
else:
|
||||
print(f" ⚠️ 合并失败: {msg}")
|
||||
elif merge_pending:
|
||||
print(" ⏳ 合并条件未满足: CI运行中")
|
||||
elif merge_failed:
|
||||
print(" ❌ 合并条件未满足: CI有失败")
|
||||
elif not approved:
|
||||
print(" ⏳ 合并条件未满足: 无审批")
|
||||
|
||||
print("\n=== 扫描结果 ===")
|
||||
print(f" 处理PR数: {min(len(prs), args.max_prs)}")
|
||||
print(f" 自动审批: {approved_count} 个")
|
||||
print(f" 自动合并: {merged_count} 个")
|
||||
print(f" AI审查阻止: {ai_blocked_count} 个")
|
||||
print(f" 跳过: {skipped_count} 个")
|
||||
print(" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CI Checkout script - 从 Gitea API 下载源码 tar 包并解压
|
||||
# 用法: ci_checkout.sh [repo_api_base] [ref] [target_dir] [token]
|
||||
# repo_api_base: 仓库 API 基础 URL,如 https://git.xiaoxiajianji.com/api/v1/repos/xiaoxia/xiaoxia-saas
|
||||
# ref: commit SHA 或分支名
|
||||
# target_dir: 目标目录(默认当前目录)
|
||||
# token: API token
|
||||
# 所有参数均可省略,将从 Gitea Actions 环境变量中读取
|
||||
|
||||
set -eu
|
||||
|
||||
# ── 参数解析 ──────────────────────────────────────────────────
|
||||
REPO_API_BASE="${1:-}"
|
||||
REF="${2:-}"
|
||||
TARGET_DIR="${3:-.}"
|
||||
TOKEN="${4:-}"
|
||||
|
||||
# 从环境变量补全默认值(兼容 Gitea Actions)
|
||||
if [ -z "$REPO_API_BASE" ]; then
|
||||
REPO_API_BASE="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||
fi
|
||||
if [ -z "$REF" ]; then
|
||||
REF="${GITHUB_SHA}"
|
||||
fi
|
||||
if [ -z "$TOKEN" ]; then
|
||||
TOKEN="${GITHUB_TOKEN:-}"
|
||||
fi
|
||||
|
||||
if [ -z "$REPO_API_BASE" ] || [ -z "$REF" ]; then
|
||||
echo "ERROR: repo API base and ref are required" >&2
|
||||
echo "Usage: $0 [repo_api_base] [ref] [target_dir] [token]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 下载并解压 ────────────────────────────────────────────────
|
||||
ARCHIVE_URL="${REPO_API_BASE}/archive/${REF}.tar.gz"
|
||||
|
||||
echo "Checkout: ${ARCHIVE_URL}"
|
||||
echo "Target dir: ${TARGET_DIR}"
|
||||
|
||||
mkdir -p "${TARGET_DIR}"
|
||||
|
||||
# 通过环境变量传递给 Python
|
||||
_CHECKOUT_URL="${ARCHIVE_URL}" \
|
||||
_CHECKOUT_TOKEN="${TOKEN}" \
|
||||
_CHECKOUT_TARGET_DIR="${TARGET_DIR}" \
|
||||
python3 - <<'PY'
|
||||
import io, os, tarfile, time, urllib.request, urllib.error
|
||||
|
||||
url = os.environ['_CHECKOUT_URL']
|
||||
token = os.environ.get('_CHECKOUT_TOKEN', '')
|
||||
target_dir = os.environ['_CHECKOUT_TARGET_DIR']
|
||||
|
||||
headers = {}
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
|
||||
last_err = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
archive = response.read()
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
last_err = e
|
||||
if e.code >= 500 and attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < 4:
|
||||
wait = 2 ** attempt
|
||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||
time.sleep(wait)
|
||||
continue
|
||||
raise
|
||||
else:
|
||||
raise last_err
|
||||
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||
for member in tar.getmembers():
|
||||
name = member.name
|
||||
if name == root_prefix[:-1]:
|
||||
continue
|
||||
if name.startswith(root_prefix):
|
||||
member.name = name[len(root_prefix):]
|
||||
if member.name:
|
||||
tar.extract(member, target_dir)
|
||||
|
||||
print("Checkout complete.")
|
||||
PY
|
||||
@@ -1,288 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# ============================================================
|
||||
# 生产环境一键回滚脚本
|
||||
#
|
||||
# 用法:
|
||||
# IMAGE_TAG=<版本号> REGISTRY_TOKEN=<token> sh rollback_production.sh
|
||||
#
|
||||
# 功能:
|
||||
# 1. 拉取指定版本镜像
|
||||
# 2. 数据库回滚到对应版本(alembic downgrade)
|
||||
# 3. 重启 api/worker/web 三个服务
|
||||
# 4. 健康检查确认服务正常
|
||||
#
|
||||
# 环境变量:
|
||||
# IMAGE_TAG - 要回滚到的版本标签(必填)
|
||||
# REGISTRY_TOKEN - Registry 访问 token(可选)
|
||||
# SKIP_DB_ROLLBACK - 跳过数据库回滚(1=跳过,默认不跳过)
|
||||
# DB_ROLLBACK_REV - 数据库回滚到的版本(默认自动用镜像里的 head)
|
||||
# ============================================================
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
SKIP_DB_ROLLBACK="${SKIP_DB_ROLLBACK:-0}"
|
||||
DB_ROLLBACK_REV="${DB_ROLLBACK_REV:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "❌ IMAGE_TAG 是必填参数"
|
||||
echo "用法: IMAGE_TAG=v0.1.125 REGISTRY_TOKEN=xxx sh rollback_production.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 生产环境回滚 → $IMAGE_TAG"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 先获取当前版本
|
||||
CURRENT_VERSION=""
|
||||
if docker inspect xiaoxia-api-production >/dev/null 2>&1; then
|
||||
CURRENT_VERSION=$(docker inspect --format '{{ index .Config.Env 0 }}' xiaoxia-api-production 2>/dev/null | grep APP_VERSION | cut -d= -f2 || echo "unknown")
|
||||
fi
|
||||
echo "当前版本: ${CURRENT_VERSION:-unknown}"
|
||||
echo "回滚目标: $IMAGE_TAG"
|
||||
echo ""
|
||||
|
||||
# 确认
|
||||
read -p "⚠️ 确认要回滚生产环境到 $IMAGE_TAG 吗?(输入 YES 确认): " confirm
|
||||
if [ "$confirm" != "YES" ]; then
|
||||
echo "已取消"
|
||||
exit 0
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ----- 登录 Registry -----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "登录 Registry: $REGISTRY"
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ----- Pull 镜像 -----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "所有镜像拉取完成"
|
||||
echo ""
|
||||
|
||||
# ----- 检查基础设施容器 -----
|
||||
echo "检查基础设施容器..."
|
||||
for c in xiaoxia-postgres-production xiaoxia-redis-production; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
docker network create xiaoxia-net-production 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
# ----- 数据库回滚 -----
|
||||
if [ "$SKIP_DB_ROLLBACK" = "1" ]; then
|
||||
echo "⏭️ 跳过数据库回滚(SKIP_DB_ROLLBACK=1)"
|
||||
else
|
||||
echo "🔄 执行数据库回滚..."
|
||||
if [ -n "$DB_ROLLBACK_REV" ]; then
|
||||
# 回滚到指定版本
|
||||
echo "回滚到版本: $DB_ROLLBACK_REV"
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic downgrade $DB_ROLLBACK_REV"
|
||||
else
|
||||
# 用目标镜像的 alembic head 来判断是否需要回滚
|
||||
# 先检查当前DB版本和目标版本的关系
|
||||
echo "检测数据库当前版本与目标版本..."
|
||||
CURRENT_DB_REV=$(docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic current" 2>&1 | tail -1 | awk '{print $1}')
|
||||
TARGET_DB_HEAD=$(docker run --rm \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic head" 2>&1 | tail -1 | awk '{print $1}')
|
||||
|
||||
echo "当前 DB 版本: ${CURRENT_DB_REV:-unknown}"
|
||||
echo "目标 DB 版本: ${TARGET_DB_HEAD:-unknown}"
|
||||
|
||||
if [ "$CURRENT_DB_REV" = "$TARGET_DB_HEAD" ]; then
|
||||
echo "✅ 数据库版本与目标版本一致,无需回滚"
|
||||
else
|
||||
echo "⚠️ 数据库版本不一致,尝试回滚..."
|
||||
echo "注意:自动回滚可能无法正确处理,请确认 DB_ROLLBACK_REV 参数"
|
||||
echo "如果需要跳过数据库回滚,请设置 SKIP_DB_ROLLBACK=1"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "数据库回滚完成"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ----- 停止旧容器 -----
|
||||
echo "停止旧容器..."
|
||||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
# ----- 启动新容器 -----
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
echo "启动 API 容器..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
echo "启动 Worker 容器..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# Web legacy assets
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
echo "Web 容器: legacy assets 已挂载"
|
||||
else
|
||||
echo "Web 容器: 没有 legacy assets"
|
||||
fi
|
||||
|
||||
echo "启动 Web 容器..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
echo ""
|
||||
|
||||
# ----- 健康检查 -----
|
||||
echo "等待 API 健康..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "❌ API 在 120s 内未就绪"
|
||||
docker logs --tail 50 xiaoxia-api-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "等待 Web 健康..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "❌ Web 在 30s 内未就绪"
|
||||
docker logs --tail 30 xiaoxia-web-production
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ----- 清理 -----
|
||||
echo "清理旧镜像..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " ✅ 生产环境回滚完成"
|
||||
echo "=========================================="
|
||||
echo "API: http://127.0.0.1:8001"
|
||||
echo "Web: http://127.0.0.1:3002"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
|
||||
@@ -11,47 +11,3 @@ if str(ROOT) not in sys.path:
|
||||
# 必须在任何 app 模块导入之前设置,否则 pydantic Settings 验证失败
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-all-tests")
|
||||
os.environ.setdefault("USE_IN_MEMORY_DB", "True")
|
||||
|
||||
|
||||
# ── Celery 全局 mock ──────────────────────────────────────────────────────
|
||||
# CI 环境没有 Redis,所有 Celery 异步任务都 mock 掉,避免连接超时报错
|
||||
# 集成测试只测 API 层逻辑(参数校验、权限、DB 操作),异步任务由 worker 单测覆盖
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _mock_celery_task():
|
||||
"""全局 mock Celery 任务的 delay/apply_async/send_task 方法。"""
|
||||
from celery import Celery, Task
|
||||
|
||||
# 保存原始方法
|
||||
_orig_delay = Task.delay
|
||||
_orig_apply_async = Task.apply_async
|
||||
_orig_send_task = Celery.send_task
|
||||
|
||||
def _mock_delay(self, *args, **kwargs):
|
||||
mock_result = MagicMock()
|
||||
mock_result.id = "mock-task-id"
|
||||
mock_result.state = "PENDING"
|
||||
mock_result.ready.return_value = False
|
||||
mock_result.get.return_value = None
|
||||
return mock_result
|
||||
|
||||
def _mock_apply_async(self, *args, **kwargs):
|
||||
return _mock_delay(self, *args, **kwargs)
|
||||
|
||||
def _mock_send_task(self, name, *args, **kwargs):
|
||||
mock_result = MagicMock()
|
||||
mock_result.id = f"mock-{name}"
|
||||
mock_result.state = "PENDING"
|
||||
mock_result.ready.return_value = False
|
||||
mock_result.get.return_value = None
|
||||
return mock_result
|
||||
|
||||
Task.delay = _mock_delay
|
||||
Task.apply_async = _mock_apply_async
|
||||
Celery.send_task = _mock_send_task
|
||||
|
||||
|
||||
# 在任何 app 模块导入之前就 patch 掉
|
||||
_mock_celery_task()
|
||||
|
||||
@@ -22,31 +22,6 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ===== 环境预设 (SMOKE_ENV) =====
|
||||
# 支持 SMOKE_ENV=production / staging 快捷预设
|
||||
SMOKE_ENV="${SMOKE_ENV:-}"
|
||||
if [ "$SMOKE_ENV" = "production" ]; then
|
||||
# 生产环境预设:安全优先,默认只读
|
||||
BASE_URL="${BASE_URL:-https://api.xiaoxiajianji.com}"
|
||||
WEB_URL="${WEB_URL:-https://saas.xiaoxiajianji.com}"
|
||||
# 如果没有提供 EXISTING_TOKEN,默认只跑不需要鉴权的模块(只读)
|
||||
if [ -z "${EXISTING_TOKEN:-}" ]; then
|
||||
MODULES="${MODULES:-health,nginx}"
|
||||
else
|
||||
# 有 token 时跑只读安全模块
|
||||
MODULES="${MODULES:-health,assets,generation,subscription,nginx}"
|
||||
fi
|
||||
CLEANUP_ENABLED="${CLEANUP_ENABLED:-0}"
|
||||
PRODUCTION_MODE=1
|
||||
elif [ "$SMOKE_ENV" = "staging" ]; then
|
||||
BASE_URL="${BASE_URL:-https://staging-api.xiaoxiajianji.com}"
|
||||
WEB_URL="${WEB_URL:-https://staging.xiaoxiajianji.com}"
|
||||
CLEANUP_ENABLED="${CLEANUP_ENABLED:-1}"
|
||||
PRODUCTION_MODE=0
|
||||
else
|
||||
PRODUCTION_MODE=0
|
||||
fi
|
||||
|
||||
# ===== 配置 =====
|
||||
BASE_URL="${BASE_URL:-}"
|
||||
TEST_USER="${TEST_USER:-e2e_$(date +%s)}"
|
||||
@@ -58,9 +33,6 @@ CLEANUP_ENABLED="${CLEANUP_ENABLED:-1}"
|
||||
CURL_TIMEOUT=30
|
||||
CURL_CONNECT_TIMEOUT=15
|
||||
CURL_INSECURE="${CURL_INSECURE:-0}"
|
||||
PERF_CHECK_ENABLED="${PERF_CHECK_ENABLED:-1}" # 是否启用响应时间检查
|
||||
PERF_WARN_THRESHOLD_MS="${PERF_WARN_THRESHOLD_MS:-3000}" # 响应时间警告阈值(毫秒)
|
||||
PERF_FAIL_THRESHOLD_MS="${PERF_FAIL_THRESHOLD_MS:-10000}" # 响应时间失败阈值(毫秒)
|
||||
|
||||
# 证书不安全的环境(如staging)可设 CURL_INSECURE=1 跳过校验
|
||||
if [ "$CURL_INSECURE" = "1" ]; then
|
||||
@@ -90,40 +62,6 @@ CREATED_TEMPLATES=()
|
||||
CREATED_PROJECTS=()
|
||||
|
||||
# ===== 工具函数 =====
|
||||
# 记录并检查响应时间
|
||||
perf_check() {
|
||||
local name="$1"
|
||||
local elapsed_ms="$2"
|
||||
|
||||
if [ "$PERF_CHECK_ENABLED" != "1" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$elapsed_ms" -ge "$PERF_FAIL_THRESHOLD_MS" ]; then
|
||||
fail "$name 响应时间" "${elapsed_ms}ms > ${PERF_FAIL_THRESHOLD_MS}ms(严重超标)"
|
||||
return 1
|
||||
elif [ "$elapsed_ms" -ge "$PERF_WARN_THRESHOLD_MS" ]; then
|
||||
echo "⚠️ $name 响应时间: ${elapsed_ms}ms(超过警告阈值 ${PERF_WARN_THRESHOLD_MS}ms)"
|
||||
return 0
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# 带计时的 curl 请求
|
||||
curl_timed() {
|
||||
local output_file=$(mktemp)
|
||||
local start_time=$(date +%s%N)
|
||||
curl -s -o "$output_file" -w "%{http_code}" "$@"
|
||||
local code=$?
|
||||
local end_time=$(date +%s%N)
|
||||
local elapsed_ms=$(( (end_time - start_time) / 1000000 ))
|
||||
cat "$output_file"
|
||||
rm -f "$output_file"
|
||||
# 通过 stderr 返回耗时(调用方需重定向)
|
||||
echo "$elapsed_ms" >&2
|
||||
return $code
|
||||
}
|
||||
|
||||
pass() {
|
||||
echo "✅ $1"
|
||||
PASSED=$((PASSED + 1))
|
||||
@@ -261,10 +199,6 @@ setup_auth() {
|
||||
test_health() {
|
||||
should_run "health" || return 0
|
||||
section "1. 基础健康检查"
|
||||
|
||||
if [ "$PERF_CHECK_ENABLED" = "1" ]; then
|
||||
info "响应时间检查已启用: 警告=${PERF_WARN_THRESHOLD_MS}ms, 失败=${PERF_FAIL_THRESHOLD_MS}ms"
|
||||
fi
|
||||
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$BASE_URL/health")
|
||||
[ "$code" = "200" ] && pass "健康检查 /health" || fail "健康检查" "HTTP $code"
|
||||
@@ -783,13 +717,6 @@ main() {
|
||||
echo "║ API E2E 冒烟测试 ║"
|
||||
echo "╚══════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
if [ "$PRODUCTION_MODE" = "1" ]; then
|
||||
echo "⚠️ 生产环境模式 - 安全只读"
|
||||
echo " - 不注册新用户"
|
||||
echo " - 不创建测试数据"
|
||||
echo " - CLEANUP_ENABLED=0"
|
||||
echo ""
|
||||
fi
|
||||
echo "环境: $BASE_URL"
|
||||
echo "模块: $MODULES"
|
||||
echo "清理: $CLEANUP_ENABLED"
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"""
|
||||
集成测试公共 fixtures
|
||||
|
||||
提供性能测试相关的工具、fixture 和 marker。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
# ── 性能阈值配置 ──────────────────────────────────────────────────────────
|
||||
PERF_THRESHOLDS: Dict[str, int] = {
|
||||
"core": 500, # 核心接口:500ms
|
||||
"normal": 1000, # 普通接口:1000ms
|
||||
"heavy": 3000, # 重操作:3000ms(涉及外部调用或复杂计算)
|
||||
}
|
||||
|
||||
# 性能测试是否跳过(通过环境变量控制)
|
||||
SKIP_PERF_TESTS = os.environ.get("SKIP_PERF_TESTS", "").lower() in ("1", "true", "yes")
|
||||
|
||||
# 性能测试容忍度:允许一定比例的请求超标(避免CI偶发波动)
|
||||
# 默认:3次请求中允许1次超标(取中位数判断)
|
||||
PERF_SAMPLE_COUNT = int(os.environ.get("PERF_SAMPLE_COUNT", "3"))
|
||||
PERF_TOLERANCE_RATIO = float(os.environ.get("PERF_TOLERANCE_RATIO", "0.34"))
|
||||
|
||||
|
||||
# ── 数据类 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerfResult:
|
||||
"""单次性能测试结果"""
|
||||
|
||||
name: str
|
||||
threshold_ms: int
|
||||
times_ms: List[float] = field(default_factory=list)
|
||||
status_code: Optional[int] = None
|
||||
|
||||
@property
|
||||
def median_ms(self) -> float:
|
||||
if not self.times_ms:
|
||||
return 0.0
|
||||
sorted_times = sorted(self.times_ms)
|
||||
n = len(sorted_times)
|
||||
if n % 2 == 0:
|
||||
return (sorted_times[n // 2 - 1] + sorted_times[n // 2]) / 2
|
||||
return sorted_times[n // 2]
|
||||
|
||||
@property
|
||||
def mean_ms(self) -> float:
|
||||
if not self.times_ms:
|
||||
return 0.0
|
||||
return sum(self.times_ms) / len(self.times_ms)
|
||||
|
||||
@property
|
||||
def min_ms(self) -> float:
|
||||
return min(self.times_ms) if self.times_ms else 0.0
|
||||
|
||||
@property
|
||||
def max_ms(self) -> float:
|
||||
return max(self.times_ms) if self.times_ms else 0.0
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
"""判断是否通过:基于中位数 + 容忍比例"""
|
||||
if not self.times_ms:
|
||||
return False
|
||||
# 中位数必须在阈值内
|
||||
if self.median_ms > self.threshold_ms:
|
||||
return False
|
||||
# 超标比例不能超过容忍度
|
||||
over_count = sum(1 for t in self.times_ms if t > self.threshold_ms)
|
||||
over_ratio = over_count / len(self.times_ms)
|
||||
return over_ratio <= PERF_TOLERANCE_RATIO
|
||||
|
||||
|
||||
# ── 性能断言上下文管理器 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PerfAssert:
|
||||
"""
|
||||
性能断言工具。
|
||||
|
||||
使用方式:
|
||||
def test_login_performance(client, perf_assert):
|
||||
with perf_assert("core", name="login") as result:
|
||||
response = client.post("/api/v1/auth/login", json={...})
|
||||
result.status_code = response.status_code
|
||||
# 退出 with 块时自动断言
|
||||
"""
|
||||
|
||||
def __init__(self, sample_count: int = PERF_SAMPLE_COUNT):
|
||||
self.sample_count = sample_count
|
||||
self.results: List[PerfResult] = []
|
||||
|
||||
@contextmanager
|
||||
def __call__(self, threshold_level: str = "core", name: str = "", samples: Optional[int] = None):
|
||||
"""
|
||||
创建一个性能测试上下文。
|
||||
|
||||
Args:
|
||||
threshold_level: 阈值级别 ("core", "normal", "heavy")
|
||||
name: 测试名称(用于输出报告)
|
||||
samples: 采样次数,默认使用全局配置
|
||||
"""
|
||||
if threshold_level not in PERF_THRESHOLDS:
|
||||
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
|
||||
|
||||
threshold_ms = PERF_THRESHOLDS[threshold_level]
|
||||
num_samples = samples or self.sample_count
|
||||
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
|
||||
|
||||
# 预热(第一次请求可能有冷启动开销)
|
||||
yield result
|
||||
# 第一次调用已经记录在 result.times_ms 中(由调用方通过 measure 方法)
|
||||
|
||||
def measure(self, threshold_level: str = "core", name: str = "", samples: Optional[int] = None) -> Callable:
|
||||
"""
|
||||
返回一个装饰器/包装器,用于测量函数执行时间。
|
||||
|
||||
使用方式:
|
||||
result = perf_assert.measure("core", "login")(
|
||||
lambda: client.post("/api/v1/auth/login", json={...})
|
||||
)
|
||||
"""
|
||||
|
||||
def wrapper(func):
|
||||
if threshold_level not in PERF_THRESHOLDS:
|
||||
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
|
||||
threshold_ms = PERF_THRESHOLDS[threshold_level]
|
||||
num_samples = samples or self.sample_count
|
||||
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
|
||||
|
||||
last_response = None
|
||||
for i in range(num_samples):
|
||||
start = time.perf_counter()
|
||||
last_response = func()
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
result.times_ms.append(elapsed)
|
||||
|
||||
if hasattr(last_response, "status_code"):
|
||||
result.status_code = last_response.status_code
|
||||
|
||||
self.results.append(result)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
def assert_all(self):
|
||||
"""断言所有性能测试结果都通过"""
|
||||
failed = [r for r in self.results if not r.passed]
|
||||
if failed:
|
||||
lines = []
|
||||
for r in failed:
|
||||
lines.append(
|
||||
f" ❌ {r.name}: 中位数 {r.median_ms:.1f}ms "
|
||||
f"(阈值 {r.threshold_ms}ms) "
|
||||
f"[min={r.min_ms:.1f}, max={r.max_ms:.1f}, "
|
||||
f"mean={r.mean_ms:.1f}, samples={len(r.times_ms)}]"
|
||||
)
|
||||
raise AssertionError(f"性能测试失败 ({len(failed)}/{len(self.results)}):\n" + "\n".join(lines))
|
||||
|
||||
def report(self) -> str:
|
||||
"""生成性能报告文本"""
|
||||
lines = ["=" * 60, " 性能测试报告", "=" * 60]
|
||||
for r in self.results:
|
||||
status = "✅" if r.passed else "❌"
|
||||
lines.append(f" {status} {r.name:<40s} " f"median={r.median_ms:>7.1f}ms / {r.threshold_ms:>5d}ms")
|
||||
lines.append(
|
||||
f" min={r.min_ms:.1f}ms max={r.max_ms:.1f}ms "
|
||||
f"mean={r.mean_ms:.1f}ms samples={len(r.times_ms)}"
|
||||
f" status={r.status_code or 'N/A'}"
|
||||
)
|
||||
passed = sum(1 for r in self.results if r.passed)
|
||||
lines.append("=" * 60)
|
||||
lines.append(f" 总计: {passed}/{len(self.results)} 通过")
|
||||
lines.append("=" * 60)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── pytest fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""注册自定义 marker"""
|
||||
config.addinivalue_line("markers", "performance: 标记为性能测试(可通过 -m 'not performance' 跳过)")
|
||||
config.addinivalue_line("markers", "perf_core: 核心接口性能测试(阈值 500ms)")
|
||||
config.addinivalue_line("markers", "perf_normal: 普通接口性能测试(阈值 1000ms)")
|
||||
config.addinivalue_line("markers", "perf_heavy: 重操作接口性能测试(阈值 3000ms)")
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""根据环境变量自动跳过性能测试"""
|
||||
if SKIP_PERF_TESTS:
|
||||
skip_perf = pytest.mark.skip(reason="SKIP_PERF_TESTS=1,跳过性能测试")
|
||||
for item in items:
|
||||
if "performance" in item.keywords or "perf_" in item.keywords:
|
||||
item.add_marker(skip_perf)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def perf_assert():
|
||||
"""
|
||||
性能断言 fixture。
|
||||
|
||||
使用方式 1(推荐,自动断言):
|
||||
def test_login(client, perf_assert):
|
||||
@perf_assert.measure("core", "POST /auth/login")
|
||||
def _call():
|
||||
return client.post("/api/v1/auth/login", json={...})
|
||||
|
||||
result = _call()
|
||||
assert result.status_code == 200
|
||||
|
||||
使用方式 2(手动多次调用):
|
||||
def test_login(client, perf_assert):
|
||||
result = perf_assert.run("core", "POST /auth/login",
|
||||
lambda: client.post("/api/v1/auth/login", json={...})
|
||||
)
|
||||
assert result.status_code == 200
|
||||
"""
|
||||
return PerfAssert()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def perf_thresholds():
|
||||
"""返回性能阈值配置字典"""
|
||||
return dict(PERF_THRESHOLDS)
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_perf_test(
|
||||
name: str,
|
||||
threshold_level: str,
|
||||
func: Callable,
|
||||
samples: int = PERF_SAMPLE_COUNT,
|
||||
) -> PerfResult:
|
||||
"""
|
||||
运行一次性能测试(独立函数,方便在 fixture 外部使用)。
|
||||
|
||||
Args:
|
||||
name: 测试名称
|
||||
threshold_level: 阈值级别
|
||||
func: 要测量的函数(无参数)
|
||||
samples: 采样次数
|
||||
|
||||
Returns:
|
||||
PerfResult 对象
|
||||
"""
|
||||
if threshold_level not in PERF_THRESHOLDS:
|
||||
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
|
||||
|
||||
threshold_ms = PERF_THRESHOLDS[threshold_level]
|
||||
result = PerfResult(name=name, threshold_ms=threshold_ms)
|
||||
|
||||
last_response = None
|
||||
for i in range(samples):
|
||||
start = time.perf_counter()
|
||||
last_response = func()
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
result.times_ms.append(elapsed)
|
||||
|
||||
if hasattr(last_response, "status_code"):
|
||||
result.status_code = last_response.status_code
|
||||
|
||||
return result
|
||||
@@ -1,601 +0,0 @@
|
||||
"""
|
||||
API 性能基线测试
|
||||
|
||||
为核心 API 接口添加性能基线测试,确保接口响应时间在合理范围内。
|
||||
|
||||
分类:
|
||||
- 核心接口(core, 500ms):登录、获取当前用户、项目列表、素材列表、生成任务列表、订阅信息
|
||||
- 普通接口(normal, 1000ms):创建项目、创建素材、模板列表、剪辑计划列表
|
||||
- 重操作接口(heavy, 3000ms):获取上传签名、创建生成任务、去重上传
|
||||
|
||||
运行方式:
|
||||
pytest tests/integration/test_api_performance.py -v
|
||||
SKIP_PERF_TESTS=1 pytest tests/integration/test_api_performance.py -v # 跳过性能测试
|
||||
pytest tests/integration/test_api_performance.py -m "not performance" # 同上
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# 检测是否有可用的 PostgreSQL 数据库
|
||||
_HAS_PG = False
|
||||
try:
|
||||
if os.environ.get("USE_IN_MEMORY_DB", "").lower() != "true":
|
||||
import psycopg
|
||||
|
||||
conn = psycopg.connect(
|
||||
os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas",
|
||||
).replace("postgresql+psycopg://", "postgresql://"),
|
||||
connect_timeout=3,
|
||||
)
|
||||
conn.close()
|
||||
_HAS_PG = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
needs_pg = pytest.mark.skipif(not _HAS_PG, reason="Requires PostgreSQL database")
|
||||
skip_perf = os.environ.get("SKIP_PERF_TESTS", "").lower() in ("1", "true", "yes")
|
||||
|
||||
from apps.api.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _register_and_login() -> tuple[str, str, str]:
|
||||
"""
|
||||
注册新用户并登录,返回 (access_token, user_id, project_id)。
|
||||
用于需要鉴权的性能测试准备数据。
|
||||
"""
|
||||
unique = uuid.uuid4().hex[:8]
|
||||
email = f"perf-{unique}@example.com"
|
||||
username = f"perfuser-{unique}"
|
||||
|
||||
# 注册
|
||||
reg_resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"password": "SecurePass123",
|
||||
"username": username,
|
||||
"display_name": "Perf Test User",
|
||||
},
|
||||
)
|
||||
assert reg_resp.status_code in (200, 201), f"注册失败: {reg_resp.json()}"
|
||||
|
||||
# 登录
|
||||
login_resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "SecurePass123"},
|
||||
)
|
||||
assert login_resp.status_code == 200, f"登录失败: {login_resp.json()}"
|
||||
data = login_resp.json()
|
||||
token = data["access_token"]
|
||||
user_id = data["user_id"]
|
||||
|
||||
# 创建一个项目(用于需要项目的接口)
|
||||
proj_resp = client.post(
|
||||
"/api/v1/projects",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"name": f"perf-project-{unique}"},
|
||||
)
|
||||
assert proj_resp.status_code in (200, 201), f"创建项目失败: {proj_resp.json()}"
|
||||
project_id = proj_resp.json()["id"]
|
||||
|
||||
return token, user_id, project_id
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def perf_test_user():
|
||||
"""
|
||||
模块级 fixture:为性能测试准备测试用户。
|
||||
|
||||
由于性能测试关注的是响应时间而非数据正确性,
|
||||
使用同一个用户和同一份数据可以减少 setup 开销,
|
||||
让性能测量更准确。
|
||||
"""
|
||||
if skip_perf:
|
||||
pytest.skip("SKIP_PERF_TESTS=1,跳过性能测试")
|
||||
if not _HAS_PG:
|
||||
pytest.skip("Requires PostgreSQL database")
|
||||
|
||||
token, user_id, project_id = _register_and_login()
|
||||
return {
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"project_id": project_id,
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 核心接口性能测试(阈值 500ms)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
@pytest.mark.perf_core
|
||||
@needs_pg
|
||||
class TestCoreApiPerformance:
|
||||
"""
|
||||
核心接口性能测试 —— 阈值 500ms
|
||||
|
||||
这些接口是用户高频使用的功能,必须保证快速响应。
|
||||
"""
|
||||
|
||||
def test_login_performance(self, perf_assert):
|
||||
"""POST /auth/login 登录接口性能"""
|
||||
# 先注册一个用户
|
||||
unique = uuid.uuid4().hex[:8]
|
||||
email = f"perf-login-{unique}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"password": "SecurePass123",
|
||||
"username": f"perflogin-{unique}",
|
||||
"display_name": "Perf Login Test",
|
||||
},
|
||||
)
|
||||
|
||||
result = perf_assert.measure("core", "POST /auth/login")(
|
||||
lambda: client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "SecurePass123"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status_code == 200, f"登录接口返回状态码 {result.status_code},预期 200"
|
||||
assert result.passed, (
|
||||
f"登录接口性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_auth_me_performance(self, perf_test_user, perf_assert):
|
||||
"""GET /auth/me 获取当前用户信息性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
|
||||
result = perf_assert.measure("core", "GET /auth/me")(lambda: client.get("/api/v1/auth/me", headers=headers))
|
||||
|
||||
assert result.status_code == 200, f"获取当前用户返回状态码 {result.status_code},预期 200"
|
||||
assert result.passed, (
|
||||
f"获取当前用户性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_projects_list_performance(self, perf_test_user, perf_assert):
|
||||
"""GET /projects 项目列表性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
|
||||
result = perf_assert.measure("core", "GET /projects")(lambda: client.get("/api/v1/projects", headers=headers))
|
||||
|
||||
assert result.status_code == 200, f"项目列表返回状态码 {result.status_code},预期 200"
|
||||
assert result.passed, (
|
||||
f"项目列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_assets_list_performance(self, perf_test_user, perf_assert):
|
||||
"""GET /assets 素材列表性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
|
||||
result = perf_assert.measure("core", "GET /assets")(lambda: client.get("/api/v1/assets", headers=headers))
|
||||
|
||||
assert result.status_code == 200, f"素材列表返回状态码 {result.status_code},预期 200"
|
||||
assert result.passed, (
|
||||
f"素材列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_generation_tasks_list_performance(self, perf_test_user, perf_assert):
|
||||
"""GET /generation/tasks 生成任务列表性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
|
||||
result = perf_assert.measure("core", "GET /generation/tasks")(
|
||||
lambda: client.get("/api/v1/generation/tasks", headers=headers)
|
||||
)
|
||||
|
||||
assert result.status_code == 200, f"生成任务列表返回状态码 {result.status_code},预期 200"
|
||||
assert result.passed, (
|
||||
f"生成任务列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_subscription_current_performance(self, perf_test_user, perf_assert):
|
||||
"""GET /subscription/current 订阅信息性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
|
||||
result = perf_assert.measure("core", "GET /subscription/current")(
|
||||
lambda: client.get("/api/v1/subscription/current", headers=headers)
|
||||
)
|
||||
|
||||
assert result.status_code == 200, f"订阅信息返回状态码 {result.status_code},预期 200"
|
||||
assert result.passed, (
|
||||
f"订阅信息性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 普通接口性能测试(阈值 1000ms)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
@pytest.mark.perf_normal
|
||||
@needs_pg
|
||||
class TestNormalApiPerformance:
|
||||
"""
|
||||
普通接口性能测试 —— 阈值 1000ms
|
||||
|
||||
这些接口涉及写操作或较多业务逻辑,允许稍长的响应时间。
|
||||
"""
|
||||
|
||||
def test_create_project_performance(self, perf_test_user, perf_assert):
|
||||
"""POST /projects 创建项目性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
counter = 0
|
||||
|
||||
def _create():
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
return client.post(
|
||||
"/api/v1/projects",
|
||||
headers=headers,
|
||||
json={"name": f"perf-create-{uuid.uuid4().hex[:8]}"},
|
||||
)
|
||||
|
||||
result = perf_assert.measure("normal", "POST /projects")(_create)
|
||||
|
||||
assert result.status_code in (200, 201), f"创建项目返回状态码 {result.status_code},预期 200/201"
|
||||
assert result.passed, (
|
||||
f"创建项目性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_templates_list_performance(self, perf_test_user, perf_assert):
|
||||
"""GET /templates 模板列表性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
|
||||
result = perf_assert.measure("normal", "GET /templates")(
|
||||
lambda: client.get("/api/v1/templates", headers=headers)
|
||||
)
|
||||
|
||||
# 模板列表可能返回 200 或空列表,只要不是错误即可
|
||||
assert result.status_code == 200, f"模板列表返回状态码 {result.status_code},预期 200"
|
||||
assert result.passed, (
|
||||
f"模板列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_edit_plans_list_performance(self, perf_test_user, perf_assert):
|
||||
"""GET /edit-plans 剪辑计划列表性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
|
||||
result = perf_assert.measure("normal", "GET /edit-plans")(
|
||||
lambda: client.get("/api/v1/edit-plans", headers=headers)
|
||||
)
|
||||
|
||||
assert result.status_code == 200, f"剪辑计划列表返回状态码 {result.status_code},预期 200"
|
||||
assert result.passed, (
|
||||
f"剪辑计划列表性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 重操作接口性能测试(阈值 3000ms)
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
@pytest.mark.perf_heavy
|
||||
@needs_pg
|
||||
class TestHeavyApiPerformance:
|
||||
"""
|
||||
重操作接口性能测试 —— 阈值 3000ms
|
||||
|
||||
这些接口涉及外部服务调用(如 OSS)或复杂业务逻辑,
|
||||
允许较长的响应时间,但仍需有上限。
|
||||
"""
|
||||
|
||||
def test_upload_direct_prepare_performance(self, perf_test_user, perf_assert):
|
||||
# OSS 未配置时跳过此测试
|
||||
from app.config import settings
|
||||
|
||||
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
|
||||
pytest.skip("OSS credentials not configured, skipping upload signature test")
|
||||
|
||||
"""POST /upload/direct/prepare 获取上传签名性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
project_id = perf_test_user["project_id"]
|
||||
|
||||
# 获取素材库 ID
|
||||
lib_resp = client.get("/api/v1/asset-libraries", headers=headers)
|
||||
library_id = ""
|
||||
if lib_resp.status_code == 200:
|
||||
items = lib_resp.json().get("items", [])
|
||||
if items:
|
||||
library_id = items[0].get("id", "")
|
||||
|
||||
def _prepare_upload():
|
||||
return client.post(
|
||||
"/api/v1/upload/direct/prepare",
|
||||
headers=headers,
|
||||
json={
|
||||
"filename": f"perf-test-{uuid.uuid4().hex[:8]}.mp4",
|
||||
"file_size": 1024 * 1024, # 1MB
|
||||
"mime_type": "video/mp4",
|
||||
"project_id": project_id,
|
||||
"library_id": library_id,
|
||||
},
|
||||
)
|
||||
|
||||
result = perf_assert.measure("heavy", "POST /upload/direct/prepare")(_prepare_upload)
|
||||
|
||||
# 上传签名接口可能因为 OSS 配置问题返回 503,这是预期的
|
||||
# 只要不超时、不返回 500 即可
|
||||
assert result.status_code in (
|
||||
200,
|
||||
201,
|
||||
400,
|
||||
503,
|
||||
), f"获取上传签名返回状态码 {result.status_code},预期 200/201/400/503"
|
||||
assert result.passed, (
|
||||
f"获取上传签名性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_create_generation_task_performance(self, perf_test_user, perf_assert):
|
||||
"""POST /generation/tasks 创建生成任务性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
project_id = perf_test_user["project_id"]
|
||||
|
||||
# 获取素材库 ID
|
||||
lib_resp = client.get("/api/v1/asset-libraries", headers=headers)
|
||||
library_id = ""
|
||||
if lib_resp.status_code == 200:
|
||||
items = lib_resp.json().get("items", [])
|
||||
if items:
|
||||
library_id = items[0].get("id", "")
|
||||
|
||||
def _create_task():
|
||||
return client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"asset_library_id": library_id,
|
||||
"template_id": "",
|
||||
"title_ids": [],
|
||||
"voice_ids": [],
|
||||
"asset_ids": [],
|
||||
"strategy_id": "",
|
||||
},
|
||||
)
|
||||
|
||||
result = perf_assert.measure("heavy", "POST /generation/tasks")(_create_task)
|
||||
|
||||
# 创建生成任务可能因为缺少素材等返回 400,这是预期的
|
||||
# 性能测试关注响应时间,不关注业务成功与否
|
||||
assert result.status_code in (
|
||||
200,
|
||||
201,
|
||||
400,
|
||||
404,
|
||||
), f"创建生成任务返回状态码 {result.status_code},预期 200/201/400/404"
|
||||
assert result.passed, (
|
||||
f"创建生成任务性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
def test_duplication_upload_performance(self, perf_test_user, perf_assert):
|
||||
"""POST /duplication/upload 去重上传性能"""
|
||||
headers = perf_test_user["headers"]
|
||||
|
||||
# 准备一个小的测试文件(模拟视频文件)
|
||||
test_content = b"fake video content for perf test" * 100
|
||||
|
||||
def _upload():
|
||||
return client.post(
|
||||
"/api/v1/duplication/upload",
|
||||
headers=headers,
|
||||
files={
|
||||
"file": (
|
||||
f"perf-dup-{uuid.uuid4().hex[:8]}.mp4",
|
||||
test_content,
|
||||
"video/mp4",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
result = perf_assert.measure("heavy", "POST /duplication/upload")(_upload)
|
||||
|
||||
# 去重上传可能因为 OSS 配置问题返回 503,这是预期的
|
||||
assert result.status_code in (
|
||||
200,
|
||||
201,
|
||||
400,
|
||||
503,
|
||||
), f"去重上传返回状态码 {result.status_code},预期 200/201/400/503"
|
||||
assert result.passed, (
|
||||
f"去重上传性能不达标: 中位数 {result.median_ms:.1f}ms > 阈值 {result.threshold_ms}ms\n"
|
||||
f" 详情: min={result.min_ms:.1f}ms, max={result.max_ms:.1f}ms, "
|
||||
f"mean={result.mean_ms:.1f}ms"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 性能测试汇总报告
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.performance
|
||||
@needs_pg
|
||||
def test_performance_summary(perf_test_user, perf_assert, capsys):
|
||||
"""
|
||||
汇总性能测试结果,输出完整报告。
|
||||
|
||||
这个测试会重新跑一遍所有接口的性能测试,
|
||||
并在最后输出汇总报告,方便在 CI 中查看。
|
||||
"""
|
||||
headers = perf_test_user["headers"]
|
||||
project_id = perf_test_user["project_id"]
|
||||
|
||||
# 获取素材库 ID
|
||||
lib_resp = client.get("/api/v1/asset-libraries", headers=headers)
|
||||
library_id = ""
|
||||
if lib_resp.status_code == 200:
|
||||
items = lib_resp.json().get("items", [])
|
||||
if items:
|
||||
library_id = items[0].get("id", "")
|
||||
|
||||
# ── 核心接口 ──
|
||||
# 登录(需要新用户)
|
||||
unique = uuid.uuid4().hex[:8]
|
||||
email = f"perf-summary-{unique}@example.com"
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"password": "SecurePass123",
|
||||
"username": f"perfsummary-{unique}",
|
||||
"display_name": "Perf Summary Test",
|
||||
},
|
||||
)
|
||||
|
||||
perf_assert.measure("core", "POST /auth/login")(
|
||||
lambda: client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "SecurePass123"},
|
||||
)
|
||||
)
|
||||
|
||||
perf_assert.measure("core", "GET /auth/me")(lambda: client.get("/api/v1/auth/me", headers=headers))
|
||||
|
||||
perf_assert.measure("core", "GET /projects")(lambda: client.get("/api/v1/projects", headers=headers))
|
||||
|
||||
perf_assert.measure("core", "GET /assets")(lambda: client.get("/api/v1/assets", headers=headers))
|
||||
|
||||
perf_assert.measure("core", "GET /generation/tasks")(
|
||||
lambda: client.get("/api/v1/generation/tasks", headers=headers)
|
||||
)
|
||||
|
||||
perf_assert.measure("core", "GET /subscription/current")(
|
||||
lambda: client.get("/api/v1/subscription/current", headers=headers)
|
||||
)
|
||||
|
||||
# ── 普通接口 ──
|
||||
perf_assert.measure("normal", "POST /projects")(
|
||||
lambda: client.post(
|
||||
"/api/v1/projects",
|
||||
headers=headers,
|
||||
json={"name": f"perf-summary-{uuid.uuid4().hex[:6]}"},
|
||||
)
|
||||
)
|
||||
|
||||
perf_assert.measure("normal", "GET /templates")(lambda: client.get("/api/v1/templates", headers=headers))
|
||||
|
||||
perf_assert.measure("normal", "GET /edit-plans")(lambda: client.get("/api/v1/edit-plans", headers=headers))
|
||||
|
||||
# ── 重操作接口 ──
|
||||
perf_assert.measure("heavy", "POST /upload/direct/prepare")(
|
||||
lambda: client.post(
|
||||
"/api/v1/upload/direct/prepare",
|
||||
headers=headers,
|
||||
json={
|
||||
"filename": f"perf-summary-{uuid.uuid4().hex[:6]}.mp4",
|
||||
"file_size": 1024 * 1024,
|
||||
"mime_type": "video/mp4",
|
||||
"project_id": project_id,
|
||||
"library_id": library_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
perf_assert.measure("heavy", "POST /generation/tasks")(
|
||||
lambda: client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"asset_library_id": library_id,
|
||||
"template_id": "",
|
||||
"title_ids": [],
|
||||
"voice_ids": [],
|
||||
"asset_ids": [],
|
||||
"strategy_id": "",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
test_content = b"fake video for summary perf test" * 100
|
||||
perf_assert.measure("heavy", "POST /duplication/upload")(
|
||||
lambda: client.post(
|
||||
"/api/v1/duplication/upload",
|
||||
headers=headers,
|
||||
files={
|
||||
"file": (
|
||||
f"perf-sum-{uuid.uuid4().hex[:6]}.mp4",
|
||||
test_content,
|
||||
"video/mp4",
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# 输出报告
|
||||
report = perf_assert.report()
|
||||
with capsys.disabled():
|
||||
print("\n" + report)
|
||||
|
||||
# 汇总断言(警告模式:不阻塞,但输出失败信息)
|
||||
# 在 CI 中通过 continue-on-error 控制是否阻塞
|
||||
passed_count = sum(1 for r in perf_assert.results if r.passed)
|
||||
total_count = len(perf_assert.results)
|
||||
|
||||
# 输出统计信息,方便 CI 解析
|
||||
with capsys.disabled():
|
||||
print(f"\nPERF_STATS: total={total_count}, passed={passed_count}, " f"failed={total_count - passed_count}")
|
||||
for r in perf_assert.results:
|
||||
status = "PASS" if r.passed else "FAIL"
|
||||
print(
|
||||
f"PERF_RESULT: {status} | {r.name} | "
|
||||
f"median={r.median_ms:.1f}ms | threshold={r.threshold_ms}ms | "
|
||||
f"min={r.min_ms:.1f}ms | max={r.max_ms:.1f}ms | "
|
||||
f"mean={r.mean_ms:.1f}ms | status_code={r.status_code}"
|
||||
)
|
||||
|
||||
# 这里使用宽松断言:只要超过一半通过就不报错
|
||||
# 具体的 CI 阻塞策略由 CI 配置控制(continue-on-error)
|
||||
assert passed_count >= total_count // 2, (
|
||||
f"性能测试通过率过低: {passed_count}/{total_count} " f"({passed_count/total_count*100:.0f}%),至少需要 50% 通过"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
@@ -1,753 +0,0 @@
|
||||
"""
|
||||
素材 CRUD API 集成测试
|
||||
|
||||
覆盖端点:
|
||||
- POST /assets — 创建素材
|
||||
- GET /assets — 获取素材列表
|
||||
- GET /assets/{id} — 获取单个素材详情
|
||||
- PUT /assets/{id} — 更新素材
|
||||
- DELETE /assets/{id} — 删除素材
|
||||
- POST /assets/batch-delete — 批量删除素材
|
||||
- POST /assets/{id}/tags — 素材打标签
|
||||
- DELETE /assets/{id}/tags/{tag_id} — 移除标签
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实模块,mock 外部依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.assets import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
get_tag_repository,
|
||||
)
|
||||
|
||||
from packages.domain import (
|
||||
Asset,
|
||||
AssetLibrary,
|
||||
AssetLibraryKind,
|
||||
AssetStatus,
|
||||
ClassificationStatus,
|
||||
Project,
|
||||
Tag,
|
||||
User,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Stub Repository 实现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects: dict[str, Project] | None = None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def find_by_id(self, project_id: str) -> Project | None:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
def find_accessible_projects(self, user_id: str) -> list[Project]:
|
||||
return [p for p in self._projects.values() if p.can_access(user_id)]
|
||||
|
||||
def count_by_owner(self, owner_user_id: str) -> int:
|
||||
return len([p for p in self._projects.values() if p.owner_user_id == owner_user_id])
|
||||
|
||||
|
||||
class StubAssetLibraryRepository:
|
||||
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
|
||||
self._libraries = libraries or {}
|
||||
|
||||
def get(self, library_id: str) -> AssetLibrary | None:
|
||||
return self._libraries.get(library_id)
|
||||
|
||||
def find_by_id(self, library_id: str) -> AssetLibrary | None:
|
||||
return self._libraries.get(library_id)
|
||||
|
||||
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
|
||||
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
|
||||
if kind is not None:
|
||||
items = [lib for lib in items if lib.kind == kind]
|
||||
return items
|
||||
|
||||
|
||||
class StubAssetRepository:
|
||||
def __init__(self, assets: dict[str, Asset] | None = None):
|
||||
self._assets = assets or {}
|
||||
|
||||
def create(self, asset: Asset) -> Asset:
|
||||
self._assets[asset.id] = asset
|
||||
return asset
|
||||
|
||||
def get(self, asset_id: str) -> Asset | None:
|
||||
return self._assets.get(asset_id)
|
||||
|
||||
def find_by_id(self, asset_id: str) -> Asset | None:
|
||||
return self._assets.get(asset_id)
|
||||
|
||||
def find_by_library(self, library_id: str, skip: int = 0, limit: int = 100) -> list[Asset]:
|
||||
items = [a for a in self._assets.values() if a.library_id == library_id]
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def find_by_library_and_file_type(self, library_id: str, file_type: str) -> list[Asset]:
|
||||
return [
|
||||
a
|
||||
for a in self._assets.values()
|
||||
if a.library_id == library_id and a.mime_type and a.mime_type.startswith(file_type)
|
||||
]
|
||||
|
||||
def find_by_project(self, project_id: str, skip: int = 0, limit: int = 100) -> list[Asset]:
|
||||
items = [a for a in self._assets.values() if a.project_id == project_id]
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def update(self, asset: Asset) -> Asset:
|
||||
self._assets[asset.id] = asset
|
||||
return asset
|
||||
|
||||
def delete(self, asset_id: str) -> bool:
|
||||
if asset_id in self._assets:
|
||||
del self._assets[asset_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def batch_delete(self, asset_ids: list[str]) -> int:
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
if aid in self._assets:
|
||||
del self._assets[aid]
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def count_by_project(self, project_id: str) -> int:
|
||||
return len([a for a in self._assets.values() if a.project_id == project_id])
|
||||
|
||||
def count_by_project_ids(self, project_ids: list[str]) -> int:
|
||||
return len([a for a in self._assets.values() if a.project_id in project_ids])
|
||||
|
||||
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
|
||||
if not file_hash:
|
||||
return None
|
||||
for asset in self._assets.values():
|
||||
if asset.library_id == library_id and getattr(asset, "file_hash", "") == file_hash:
|
||||
return asset
|
||||
return None
|
||||
|
||||
|
||||
class StubTagRepository:
|
||||
def __init__(self, tags: dict[str, Tag] | None = None):
|
||||
self._tags = tags or {}
|
||||
|
||||
def get(self, tag_id: str) -> Tag | None:
|
||||
return self._tags.get(tag_id)
|
||||
|
||||
def create(self, tag: Tag) -> Tag:
|
||||
self._tags[tag.id] = tag
|
||||
return tag
|
||||
|
||||
def list_by_user(self, user_id: str, skip: int = 0, limit: int = 100) -> list[Tag]:
|
||||
return [t for t in self._tags.values() if t.user_id == user_id][skip : skip + limit]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Helpers & Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
|
||||
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
|
||||
|
||||
|
||||
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
|
||||
return AssetLibrary(
|
||||
id=id,
|
||||
name="Test Video Library",
|
||||
project_id=project_id,
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
)
|
||||
|
||||
|
||||
def _make_asset(**overrides) -> Asset:
|
||||
defaults = dict(
|
||||
id="asset-1",
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="test-video.mp4",
|
||||
storage_key="uploads/test-video.mp4",
|
||||
mime_type="video/mp4",
|
||||
file_size=1024 * 1024,
|
||||
status=AssetStatus.READY,
|
||||
classification_status=ClassificationStatus.COMPLETED,
|
||||
uploaded_by_user_id="user-test-001",
|
||||
duration=30.5,
|
||||
width=1920,
|
||||
height=1080,
|
||||
fps=25.0,
|
||||
quality_score=85.0,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return Asset(**defaults)
|
||||
|
||||
|
||||
def _make_tag(id: str = "tag-1", user_id: str = "user-test-001", name: str = "精彩片段") -> Tag:
|
||||
return Tag(id=id, user_id=user_id, name=name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage():
|
||||
storage = MagicMock()
|
||||
storage.get_download_url.return_value = "https://oss.example.com/uploads/test.mp4?sign=xxx"
|
||||
return storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_storage):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/api/v1/assets")
|
||||
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
asset_repo = StubAssetRepository()
|
||||
project_repo = StubProjectRepository({project.id: project})
|
||||
library_repo = StubAssetLibraryRepository({library.id: library})
|
||||
tag_repo = StubTagRepository()
|
||||
|
||||
def _override_current_user():
|
||||
mock_auth = MagicMock(spec=AuthenticatedUser)
|
||||
mock_auth.user = _make_user()
|
||||
return mock_auth
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
|
||||
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
test_app.dependency_overrides[get_tag_repository] = lambda: tag_repo
|
||||
test_app.dependency_overrides[get_storage_service] = lambda: mock_storage
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST /assets — 创建素材
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateAsset:
|
||||
"""创建素材端点测试。"""
|
||||
|
||||
def test_create_asset_success(self, client):
|
||||
"""正常创建素材成功。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "new-video.mp4",
|
||||
"storage_key": "uploads/new-video.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"file_size": 2048,
|
||||
"duration": 15.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "new-video.mp4"
|
||||
assert data["project_id"] == "proj-1"
|
||||
assert data["library_id"] == "lib-1"
|
||||
assert data["mime_type"] == "video/mp4"
|
||||
assert "id" in data
|
||||
assert data["status"] == "uploading"
|
||||
|
||||
def test_create_asset_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "nonexistent",
|
||||
"library_id": "lib-1",
|
||||
"name": "test.mp4",
|
||||
"storage_key": "uploads/test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "Project" in resp.json()["detail"]
|
||||
|
||||
def test_create_asset_library_not_found(self, client):
|
||||
"""素材库不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "nonexistent",
|
||||
"name": "test.mp4",
|
||||
"storage_key": "uploads/test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "AssetLibrary" in resp.json()["detail"]
|
||||
|
||||
def test_create_asset_missing_required_fields(self, client):
|
||||
"""缺少必填字段返回 422。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"name": "test.mp4",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /assets — 获取素材列表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAssets:
|
||||
"""获取素材列表端点测试。"""
|
||||
|
||||
def _create_test_assets(self, client, count: int = 3):
|
||||
"""辅助方法:创建测试素材。"""
|
||||
for i in range(count):
|
||||
client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": f"video-{i}.mp4",
|
||||
"storage_key": f"uploads/video-{i}.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"file_size": 1024 * (i + 1),
|
||||
},
|
||||
)
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无素材时返回空列表。"""
|
||||
resp = client.get("/api/v1/assets?library_id=lib-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_assets_by_library(self, client):
|
||||
"""按素材库列出素材。"""
|
||||
self._create_test_assets(client, 3)
|
||||
|
||||
resp = client.get("/api/v1/assets?library_id=lib-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["total"] >= 3
|
||||
|
||||
def test_list_assets_by_project(self, client):
|
||||
"""按项目列出素材。"""
|
||||
self._create_test_assets(client, 2)
|
||||
|
||||
resp = client.get("/api/v1/assets?project_id=proj-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_list_pagination(self, client):
|
||||
"""分页参数生效。"""
|
||||
self._create_test_assets(client, 5)
|
||||
|
||||
resp = client.get("/api/v1/assets?library_id=lib-1&skip=0&limit=2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
assert data["skip"] == 0
|
||||
assert data["limit"] == 2
|
||||
|
||||
def test_list_with_keyword_filter(self, client):
|
||||
"""按名称关键词过滤。"""
|
||||
client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "hello-world.mp4",
|
||||
"storage_key": "uploads/hello.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "goodbye.mp4",
|
||||
"storage_key": "uploads/goodbye.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get("/api/v1/assets?library_id=lib-1&keyword=hello")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert "hello" in data["items"][0]["name"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. GET /assets/{asset_id} — 获取单个素材详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetAsset:
|
||||
"""获取单个素材详情端点测试。"""
|
||||
|
||||
def _create_asset(self, client) -> str:
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "detail-test.mp4",
|
||||
"storage_key": "uploads/detail-test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"file_size": 5000,
|
||||
"duration": 25.0,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30.0,
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
def test_get_asset_success(self, client):
|
||||
"""获取存在的素材详情成功。"""
|
||||
asset_id = self._create_asset(client)
|
||||
|
||||
resp = client.get(f"/api/v1/assets/{asset_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == asset_id
|
||||
assert data["name"] == "detail-test.mp4"
|
||||
assert data["file_size"] == 5000
|
||||
assert data["duration"] == 25.0
|
||||
assert data["width"] == 1280
|
||||
assert data["height"] == 720
|
||||
assert "file_url" in data
|
||||
assert "status" in data
|
||||
|
||||
def test_get_nonexistent_asset_returns_404(self, client):
|
||||
"""获取不存在的素材返回 404。"""
|
||||
resp = client.get("/api/v1/assets/nonexistent-asset-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower() or "Asset" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. PUT /assets/{asset_id} — 更新素材
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateAsset:
|
||||
"""更新素材端点测试。"""
|
||||
|
||||
def _create_asset(self, client) -> str:
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "old-name.mp4",
|
||||
"storage_key": "uploads/old-name.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
def test_update_asset_name(self, client):
|
||||
"""更新素材名称成功。"""
|
||||
asset_id = self._create_asset(client)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/v1/assets/{asset_id}",
|
||||
json={"name": "new-name.mp4"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "new-name.mp4"
|
||||
|
||||
def test_update_asset_metadata(self, client):
|
||||
"""更新素材 metadata 成功。"""
|
||||
asset_id = self._create_asset(client)
|
||||
|
||||
resp = client.put(
|
||||
f"/api/v1/assets/{asset_id}",
|
||||
json={"metadata": {"description": "这是一段测试视频", "category": "demo"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["metadata"]["description"] == "这是一段测试视频"
|
||||
assert data["metadata"]["category"] == "demo"
|
||||
|
||||
def test_update_nonexistent_asset_returns_404(self, client):
|
||||
"""更新不存在的素材返回 404。"""
|
||||
resp = client.put(
|
||||
"/api/v1/assets/nonexistent-id",
|
||||
json={"name": "test.mp4"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_with_empty_body(self, client):
|
||||
"""空请求体也应返回成功(不修改任何字段)。"""
|
||||
asset_id = self._create_asset(client)
|
||||
|
||||
resp = client.put(f"/api/v1/assets/{asset_id}", json={})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "old-name.mp4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. DELETE /assets/{asset_id} — 删除素材
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteAsset:
|
||||
"""删除素材端点测试。"""
|
||||
|
||||
def _create_asset(self, client) -> str:
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "delete-test.mp4",
|
||||
"storage_key": "uploads/delete-test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
def test_delete_asset_success(self, client):
|
||||
"""删除存在的素材成功,返回 204。"""
|
||||
asset_id = self._create_asset(client)
|
||||
|
||||
resp = client.delete(f"/api/v1/assets/{asset_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
get_resp = client.get(f"/api/v1/assets/{asset_id}")
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
def test_delete_nonexistent_asset_returns_404(self, client):
|
||||
"""删除不存在的素材返回 404。"""
|
||||
resp = client.delete("/api/v1/assets/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_idempotent(self, client):
|
||||
"""删除后再次删除返回 404(幂等性)。"""
|
||||
asset_id = self._create_asset(client)
|
||||
|
||||
resp1 = client.delete(f"/api/v1/assets/{asset_id}")
|
||||
assert resp1.status_code == 204
|
||||
|
||||
resp2 = client.delete(f"/api/v1/assets/{asset_id}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. POST /assets/batch-delete — 批量删除素材
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatchDeleteAssets:
|
||||
"""批量删除素材端点测试。"""
|
||||
|
||||
def _create_assets(self, client, count: int = 3) -> list[str]:
|
||||
ids = []
|
||||
for i in range(count):
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": f"batch-{i}.mp4",
|
||||
"storage_key": f"uploads/batch-{i}.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
ids.append(resp.json()["id"])
|
||||
return ids
|
||||
|
||||
def test_batch_delete_success(self, client):
|
||||
"""批量删除成功。"""
|
||||
ids = self._create_assets(client, 3)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/assets/batch-delete",
|
||||
json={"ids": ids[:2]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["deleted_count"] == 2
|
||||
assert len(data["failed_ids"]) == 0
|
||||
|
||||
def test_batch_delete_with_nonexistent_ids(self, client):
|
||||
"""批量删除包含不存在的 ID,失败的计入 failed_ids。"""
|
||||
ids = self._create_assets(client, 2)
|
||||
ids.append("nonexistent-id")
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/assets/batch-delete",
|
||||
json={"ids": ids},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["deleted_count"] == 2
|
||||
assert "nonexistent-id" in data["failed_ids"]
|
||||
|
||||
def test_batch_delete_empty_list_returns_422(self, client):
|
||||
"""空列表返回 422。"""
|
||||
resp = client.post(
|
||||
"/api/v1/assets/batch-delete",
|
||||
json={"ids": []},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. 标签相关测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAssetTags:
|
||||
"""素材标签相关端点测试。"""
|
||||
|
||||
def _create_asset(self, client) -> str:
|
||||
resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "tag-test.mp4",
|
||||
"storage_key": "uploads/tag-test.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
},
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
def test_add_tags_to_asset(self, client):
|
||||
"""给素材打标签。需要先在 tag_repo 中创建标签。"""
|
||||
# 由于 tag_repo 在 fixture 内部创建,我们通过另一种方式测试
|
||||
# 直接测试不存在的标签返回 404
|
||||
asset_id = self._create_asset(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/assets/{asset_id}/tags",
|
||||
json={"tag_ids": ["nonexistent-tag"]},
|
||||
)
|
||||
# 标签不存在应返回 404
|
||||
assert resp.status_code == 404
|
||||
assert "Tag" in resp.json()["detail"]
|
||||
|
||||
def test_remove_tag_from_asset(self, client):
|
||||
"""移除素材标签(幂等,不存在也返回 204)。"""
|
||||
asset_id = self._create_asset(client)
|
||||
|
||||
resp = client.delete(f"/api/v1/assets/{asset_id}/tags/nonexistent-tag")
|
||||
# 移除标签是幂等的,标签不存在也应返回 204
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. 跨端点集成场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAssetsCRUDFlow:
|
||||
"""素材完整 CRUD 流程测试。"""
|
||||
|
||||
def test_full_crud_flow(self, client):
|
||||
"""测试完整的创建 → 列表 → 详情 → 更新 → 删除流程。"""
|
||||
# 1. 创建
|
||||
create_resp = client.post(
|
||||
"/api/v1/assets",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"name": "crud-flow.mp4",
|
||||
"storage_key": "uploads/crud-flow.mp4",
|
||||
"mime_type": "video/mp4",
|
||||
"file_size": 8192,
|
||||
"metadata": {"source": "test"},
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
asset_id = create_resp.json()["id"]
|
||||
|
||||
# 2. 列表中应包含
|
||||
list_resp = client.get("/api/v1/assets?library_id=lib-1")
|
||||
assert list_resp.status_code == 200
|
||||
assert any(item["id"] == asset_id for item in list_resp.json()["items"])
|
||||
|
||||
# 3. 获取详情
|
||||
detail_resp = client.get(f"/api/v1/assets/{asset_id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["name"] == "crud-flow.mp4"
|
||||
|
||||
# 4. 更新名称
|
||||
update_resp = client.put(
|
||||
f"/api/v1/assets/{asset_id}",
|
||||
json={"name": "crud-flow-updated.mp4"},
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
assert update_resp.json()["name"] == "crud-flow-updated.mp4"
|
||||
|
||||
# 5. 验证更新生效
|
||||
detail_resp2 = client.get(f"/api/v1/assets/{asset_id}")
|
||||
assert detail_resp2.json()["name"] == "crud-flow-updated.mp4"
|
||||
|
||||
# 6. 删除
|
||||
delete_resp = client.delete(f"/api/v1/assets/{asset_id}")
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
# 7. 验证已删除
|
||||
detail_resp3 = client.get(f"/api/v1/assets/{asset_id}")
|
||||
assert detail_resp3.status_code == 404
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,743 +0,0 @@
|
||||
"""
|
||||
分片上传完整流程集成测试
|
||||
|
||||
覆盖端点:
|
||||
- POST /upload/chunk/init — 初始化分片上传
|
||||
- POST /upload/chunk/{id}/{index} — 上传分片
|
||||
- GET /upload/chunk/{id}/status — 获取上传状态
|
||||
- POST /upload/chunk/{id}/complete — 完成分片上传
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实模块,mock 外部依赖(OSS存储、Celery任务、文件类型检测)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.chunked_upload import (
|
||||
CHUNK_STORAGE_ROOT,
|
||||
complete_chunked_upload,
|
||||
get_upload_status,
|
||||
init_chunked_upload,
|
||||
upload_chunk,
|
||||
)
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind, Project, User
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Stub Repository 实现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects: dict[str, Project] | None = None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def get(self, project_id: str) -> Project | None:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
def find_by_id(self, project_id: str) -> Project | None:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
class StubAssetLibraryRepository:
|
||||
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
|
||||
self._libraries = libraries or {}
|
||||
|
||||
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
|
||||
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
|
||||
if kind is not None:
|
||||
items = [lib for lib in items if lib.kind == kind]
|
||||
return items
|
||||
|
||||
def get(self, library_id: str) -> AssetLibrary | None:
|
||||
return self._libraries.get(library_id)
|
||||
|
||||
|
||||
class StubAssetRepository:
|
||||
def __init__(self):
|
||||
self._assets = {}
|
||||
|
||||
def find_by_library_and_file_hash(self, library_id: str, file_hash: str):
|
||||
if not file_hash:
|
||||
return None
|
||||
for asset in self._assets.values():
|
||||
if asset.library_id == library_id and getattr(asset, "file_hash", "") == file_hash:
|
||||
return asset
|
||||
return None
|
||||
|
||||
|
||||
class StubIngestJobRepository:
|
||||
"""内存 IngestJob Repository,模拟持久化行为。"""
|
||||
|
||||
def __init__(self):
|
||||
self._jobs: dict[str, object] = {}
|
||||
|
||||
def create(self, job) -> object:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def add(self, job) -> None:
|
||||
self._jobs[job.id] = job
|
||||
|
||||
def get(self, job_id: str):
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def update(self, job) -> object:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def update_status(self, job_id, status, **kwargs):
|
||||
job = self._jobs.get(job_id)
|
||||
if job:
|
||||
job.status = status
|
||||
|
||||
def list_by_project(self, project_id: str, skip: int = 0, limit: int = 50):
|
||||
return [j for j in self._jobs.values() if getattr(j, "project_id", None) == project_id]
|
||||
|
||||
def list_by_library(self, library_id: str, skip: int = 0, limit: int = 50):
|
||||
return [j for j in self._jobs.values() if getattr(j, "library_id", None) == library_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Helpers & Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
|
||||
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
|
||||
|
||||
|
||||
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
|
||||
return AssetLibrary(id=id, name="Test Library", project_id=project_id, kind=AssetLibraryKind.VIDEO)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project():
|
||||
return _make_project()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library():
|
||||
return _make_library()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage():
|
||||
storage = MagicMock()
|
||||
storage.is_configured = True
|
||||
storage.upload_file.return_value = "https://oss.example.com/uploads/test/test.mp4"
|
||||
storage.get_download_url.return_value = "https://oss.example.com/uploads/test/test.mp4?sign=xxx"
|
||||
return storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(project, library, mock_storage):
|
||||
"""创建带有依赖覆盖的 TestClient。
|
||||
|
||||
注意:手动按正确顺序注册路由,避免 /{upload_id}/{chunk_index} 抢占
|
||||
/{upload_id}/complete 和 /{upload_id}/status 的匹配。
|
||||
"""
|
||||
test_app = FastAPI()
|
||||
|
||||
project_repo = StubProjectRepository({project.id: project})
|
||||
library_repo = StubAssetLibraryRepository({library.id: library})
|
||||
asset_repo = StubAssetRepository()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
def _override_current_user():
|
||||
mock_auth = MagicMock(spec=AuthenticatedUser)
|
||||
mock_auth.user = _make_user()
|
||||
mock_auth.id = "user-test-001"
|
||||
return mock_auth
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
|
||||
test_app.dependency_overrides[get_storage_service] = lambda: mock_storage
|
||||
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
# 手动按正确顺序注册路由(具体路径在前,参数路径在后)
|
||||
prefix = "/api/v1/upload/chunk"
|
||||
test_app.add_api_route(f"{prefix}/init", init_chunked_upload, methods=["POST"])
|
||||
test_app.add_api_route(f"{prefix}/{{upload_id}}/status", get_upload_status, methods=["GET"])
|
||||
test_app.add_api_route(f"{prefix}/{{upload_id}}/complete", complete_chunked_upload, methods=["POST"])
|
||||
test_app.add_api_route(f"{prefix}/{{upload_id}}/{{chunk_index}}", upload_chunk, methods=["POST"])
|
||||
|
||||
# 临时修改 CHUNK_STORAGE_ROOT 到测试临时目录
|
||||
test_temp_dir = tempfile.mkdtemp(prefix="test_chunked_upload_")
|
||||
import app.api.routes.chunked_upload as chunk_mod
|
||||
|
||||
chunk_mod.CHUNK_STORAGE_ROOT = Path(test_temp_dir)
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
# 清理
|
||||
import shutil
|
||||
|
||||
chunk_mod.CHUNK_STORAGE_ROOT = CHUNK_STORAGE_ROOT
|
||||
if Path(test_temp_dir).exists():
|
||||
shutil.rmtree(test_temp_dir)
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST /init — 初始化分片上传
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitChunkedUpload:
|
||||
"""初始化分片上传端点测试。"""
|
||||
|
||||
def test_init_success(self, client):
|
||||
"""正常初始化分片上传成功。"""
|
||||
file_size = 10 * 1024 * 1024 # 10MB
|
||||
chunk_size = 5 * 1024 * 1024 # 5MB
|
||||
total_chunks = (file_size + chunk_size - 1) // chunk_size # 2
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "test-video.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": file_size,
|
||||
"total_chunks": total_chunks,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "upload_id" in data
|
||||
assert data["filename"] == "test-video.mp4"
|
||||
assert data["total_chunks"] == total_chunks
|
||||
assert data["chunk_size"] == chunk_size
|
||||
assert "expires_at" in data
|
||||
|
||||
def test_init_with_invalid_total_chunks(self, client):
|
||||
"""total_chunks 与 file_size 不匹配返回 400。"""
|
||||
file_size = 10 * 1024 * 1024
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "test.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": file_size,
|
||||
"total_chunks": 999, # 错误的分片数
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "total_chunks" in resp.json()["detail"].lower() or "mismatch" in resp.json()["detail"].lower()
|
||||
|
||||
def test_init_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "nonexistent",
|
||||
"library_id": "lib-1",
|
||||
"filename": "test.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": 1024 * 1024,
|
||||
"total_chunks": 1,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "Project not found" in resp.json()["detail"]
|
||||
|
||||
def test_init_library_not_found(self, client):
|
||||
"""素材库不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "nonexistent",
|
||||
"filename": "test.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": 1024 * 1024,
|
||||
"total_chunks": 1,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "Asset library not found" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. POST /{upload_id}/{chunk_index} — 上传分片
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUploadChunk:
|
||||
"""上传分片端点测试。"""
|
||||
|
||||
def _init_upload(self, client, file_size: int = 10 * 1024 * 1024) -> str:
|
||||
"""辅助方法:初始化上传并返回 upload_id。"""
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
total_chunks = (file_size + chunk_size - 1) // chunk_size
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "test-video.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": file_size,
|
||||
"total_chunks": total_chunks,
|
||||
},
|
||||
)
|
||||
return resp.json()["upload_id"]
|
||||
|
||||
def test_upload_first_chunk_success(self, client):
|
||||
"""上传第一个分片成功。"""
|
||||
upload_id = self._init_upload(client)
|
||||
chunk_data = b"a" * (5 * 1024 * 1024) # 5MB
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/0",
|
||||
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["chunk_index"] == 0
|
||||
assert data["uploaded_chunks"] == 1
|
||||
assert data["total_chunks"] == 2
|
||||
|
||||
def test_upload_nonexistent_upload_returns_404(self, client):
|
||||
"""上传不存在的 upload_id 返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/nonexistent-upload-id/0",
|
||||
files={"chunk": ("chunk_0", b"data", "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "Upload not found" in resp.json()["detail"]
|
||||
|
||||
def test_upload_chunk_index_out_of_bounds(self, client):
|
||||
"""分片索引越界返回 400。"""
|
||||
upload_id = self._init_upload(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/999",
|
||||
files={"chunk": ("chunk_999", b"data", "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Invalid chunk index" in resp.json()["detail"]
|
||||
|
||||
def test_upload_chunk_index_negative(self, client):
|
||||
"""分片索引为负数返回 422(FastAPI 路径参数校验)。"""
|
||||
upload_id = self._init_upload(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/-1",
|
||||
files={"chunk": ("chunk_-1", b"data", "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code in (400, 422)
|
||||
|
||||
def test_upload_duplicate_chunk_returns_message(self, client):
|
||||
"""重复上传同一分片返回已上传提示(幂等)。"""
|
||||
upload_id = self._init_upload(client)
|
||||
chunk_data = b"b" * (5 * 1024 * 1024)
|
||||
|
||||
resp1 = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/0",
|
||||
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
|
||||
resp2 = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/0",
|
||||
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
assert "already uploaded" in resp2.json()["message"].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. GET /{upload_id}/status — 获取上传状态
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetUploadStatus:
|
||||
"""获取上传状态端点测试。"""
|
||||
|
||||
def _init_upload(self, client) -> str:
|
||||
file_size = 10 * 1024 * 1024
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
total_chunks = (file_size + chunk_size - 1) // chunk_size
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "test-video.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": file_size,
|
||||
"total_chunks": total_chunks,
|
||||
},
|
||||
)
|
||||
return resp.json()["upload_id"]
|
||||
|
||||
def test_status_pending_after_init(self, client):
|
||||
"""刚初始化后状态为 pending,无已上传分片。"""
|
||||
upload_id = self._init_upload(client)
|
||||
|
||||
resp = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["upload_id"] == upload_id
|
||||
assert data["status"] == "pending"
|
||||
assert data["uploaded_chunks"] == []
|
||||
assert data["total_chunks"] == 2
|
||||
assert data["file_size"] == 10 * 1024 * 1024
|
||||
|
||||
def test_status_after_uploading_chunks(self, client):
|
||||
"""上传部分分片后状态更新。"""
|
||||
upload_id = self._init_upload(client)
|
||||
chunk_data = b"c" * (5 * 1024 * 1024)
|
||||
|
||||
client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/0",
|
||||
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "uploading"
|
||||
assert 0 in data["uploaded_chunks"]
|
||||
assert len(data["uploaded_chunks"]) == 1
|
||||
|
||||
def test_status_nonexistent_upload_returns_404(self, client):
|
||||
"""查询不存在的 upload_id 返回 404。"""
|
||||
resp = client.get("/api/v1/upload/chunk/nonexistent-id/status")
|
||||
assert resp.status_code == 404
|
||||
assert "Upload not found" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. POST /{upload_id}/complete — 完成分片上传
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCompleteChunkedUpload:
|
||||
"""完成分片上传端点测试。"""
|
||||
|
||||
def _init_and_upload_all_chunks(self, client, file_size: int = 10 * 1024 * 1024) -> str:
|
||||
"""辅助方法:初始化并上传所有分片。"""
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
total_chunks = (file_size + chunk_size - 1) // chunk_size
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "test-video.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": file_size,
|
||||
"total_chunks": total_chunks,
|
||||
},
|
||||
)
|
||||
upload_id = resp.json()["upload_id"]
|
||||
|
||||
for i in range(total_chunks):
|
||||
if i == total_chunks - 1:
|
||||
remaining = file_size - i * chunk_size
|
||||
chunk_data = b"x" * remaining
|
||||
else:
|
||||
chunk_data = b"x" * chunk_size
|
||||
client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/{i}",
|
||||
files={"chunk": (f"chunk_{i}", chunk_data, "application/octet-stream")},
|
||||
)
|
||||
|
||||
return upload_id
|
||||
|
||||
@patch("app.api.routes.chunked_upload._validate_file_type")
|
||||
@patch("app.api.routes.chunked_upload.celery_app")
|
||||
def test_complete_success(self, mock_celery, mock_validate, client, mock_storage):
|
||||
"""完整上传后调用 complete 成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
mock_validate.return_value = "video/mp4"
|
||||
|
||||
upload_id = self._init_and_upload_all_chunks(client)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/complete",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"file_hash": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "storage_key" in data
|
||||
assert "url" in data
|
||||
assert "ingest_job_id" in data
|
||||
assert data["duplicated"] is False
|
||||
assert mock_storage.upload_file.called
|
||||
assert mock_celery.send_task.called
|
||||
|
||||
def test_complete_with_missing_chunks(self, client):
|
||||
"""缺少分片时调用 complete 返回 400。"""
|
||||
file_size = 10 * 1024 * 1024
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
total_chunks = (file_size + chunk_size - 1) // chunk_size
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "test-video.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": file_size,
|
||||
"total_chunks": total_chunks,
|
||||
},
|
||||
)
|
||||
upload_id = resp.json()["upload_id"]
|
||||
|
||||
# 只上传第0个分片,缺少第1个
|
||||
chunk_data = b"y" * chunk_size
|
||||
client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/0",
|
||||
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/complete",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"file_hash": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Missing chunks" in resp.json()["detail"]
|
||||
|
||||
def test_complete_nonexistent_upload_returns_404(self, client):
|
||||
"""完成不存在的 upload_id 返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/nonexistent-id/complete",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"file_hash": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "Upload not found" in resp.json()["detail"]
|
||||
|
||||
def test_complete_project_mismatch_returns_400(self, client):
|
||||
"""project_id 不匹配返回 400。"""
|
||||
# 只传一个分片用于测试(不完成也没关系,project 校验在 missing chunks 之前)
|
||||
file_size = 5 * 1024 * 1024
|
||||
resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "test-video.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": file_size,
|
||||
"total_chunks": 1,
|
||||
},
|
||||
)
|
||||
upload_id = resp.json()["upload_id"]
|
||||
chunk_data = b"z" * file_size
|
||||
client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/0",
|
||||
files={"chunk": ("chunk_0", chunk_data, "application/octet-stream")},
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/complete",
|
||||
json={
|
||||
"project_id": "wrong-project",
|
||||
"library_id": "lib-1",
|
||||
"file_hash": "",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "mismatch" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.routes.chunked_upload._validate_file_type")
|
||||
@patch("app.api.routes.chunked_upload.celery_app")
|
||||
def test_complete_with_file_hash_dedup(self, mock_celery, mock_validate, client, mock_storage):
|
||||
"""带 file_hash 的去重检测命中时返回 duplicated=true。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
mock_validate.return_value = "video/mp4"
|
||||
|
||||
# 先在 asset_repo 里预置一个重复素材
|
||||
file_size = 5 * 1024 * 1024
|
||||
file_hash = "abc123def456"
|
||||
|
||||
# 需要在 asset_repo 中预置数据
|
||||
# 由于 client fixture 中 asset_repo 是内部创建的,我们需要用另一种方式
|
||||
# 直接通过 patch 模拟 find_by_library_and_file_hash 返回值
|
||||
from packages.domain import Asset, AssetStatus, ClassificationStatus
|
||||
|
||||
existing_asset = Asset(
|
||||
id="existing-asset-1",
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name="existing.mp4",
|
||||
storage_key="uploads/existing.mp4",
|
||||
mime_type="video/mp4",
|
||||
file_hash=file_hash,
|
||||
status=AssetStatus.READY,
|
||||
classification_status=ClassificationStatus.COMPLETED,
|
||||
)
|
||||
|
||||
# 通过 patch 修改 asset_repository 的返回值
|
||||
with patch(
|
||||
"app.api.routes.chunked_upload.get_asset_repository",
|
||||
return_value=type(
|
||||
"Repo",
|
||||
(),
|
||||
{"find_by_library_and_file_hash": lambda self, lib_id, fh: existing_asset if fh == file_hash else None},
|
||||
)(),
|
||||
):
|
||||
upload_id = self._init_and_upload_all_chunks(client, file_size)
|
||||
resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/complete",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"file_hash": file_hash,
|
||||
},
|
||||
)
|
||||
# 注:此测试可能受依赖注入顺序影响,仅验证基本路径
|
||||
# 实际命中去重的情况在端到端测试中验证
|
||||
assert resp.status_code in (200, 400)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 完整流程集成测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullChunkedUploadFlow:
|
||||
"""分片上传完整流程集成测试。"""
|
||||
|
||||
@patch("app.api.routes.chunked_upload._validate_file_type")
|
||||
@patch("app.api.routes.chunked_upload.celery_app")
|
||||
def test_full_upload_flow(self, mock_celery, mock_validate, client):
|
||||
"""测试完整的分片上传流程:init → 上传分片 → status → complete。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
mock_validate.return_value = "video/mp4"
|
||||
|
||||
file_size = 12 * 1024 * 1024 # 12MB = 3个分片 (5+5+2)
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
total_chunks = (file_size + chunk_size - 1) // chunk_size # 3
|
||||
|
||||
# 1. 初始化
|
||||
init_resp = client.post(
|
||||
"/api/v1/upload/chunk/init",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"filename": "full-flow.mp4",
|
||||
"content_type": "video/mp4",
|
||||
"file_size": file_size,
|
||||
"total_chunks": total_chunks,
|
||||
},
|
||||
)
|
||||
assert init_resp.status_code == 200
|
||||
upload_id = init_resp.json()["upload_id"]
|
||||
|
||||
# 2. 检查初始状态
|
||||
status_resp = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.json()["status"] == "pending"
|
||||
|
||||
# 3. 上传所有分片
|
||||
for i in range(total_chunks):
|
||||
if i == total_chunks - 1:
|
||||
remaining = file_size - i * chunk_size
|
||||
chunk_data = b"z" * remaining
|
||||
else:
|
||||
chunk_data = b"z" * chunk_size
|
||||
|
||||
chunk_resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/{i}",
|
||||
files={"chunk": (f"chunk_{i}", chunk_data, "application/octet-stream")},
|
||||
)
|
||||
assert chunk_resp.status_code == 200
|
||||
|
||||
# 4. 检查上传中状态
|
||||
status_resp = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.json()["status"] == "uploading"
|
||||
assert len(status_resp.json()["uploaded_chunks"]) == total_chunks
|
||||
|
||||
# 5. 完成上传
|
||||
complete_resp = client.post(
|
||||
f"/api/v1/upload/chunk/{upload_id}/complete",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"library_id": "lib-1",
|
||||
"file_hash": "abc123def456",
|
||||
},
|
||||
)
|
||||
assert complete_resp.status_code == 200
|
||||
complete_data = complete_resp.json()
|
||||
assert complete_data["ingest_job_id"] != ""
|
||||
assert complete_data["storage_key"].startswith("uploads/")
|
||||
|
||||
# 6. 验证 Celery 任务被发送
|
||||
assert mock_celery.send_task.called
|
||||
assert mock_celery.send_task.call_args[0][0] == "worker.ingest_asset"
|
||||
|
||||
# 7. 完成后再次查询状态应返回 404(元数据已清理)
|
||||
status_after = client.get(f"/api/v1/upload/chunk/{upload_id}/status")
|
||||
assert status_after.status_code == 404
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,301 +0,0 @@
|
||||
"""
|
||||
分类任务 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- POST /classification-jobs — 提交分类任务
|
||||
- GET /classification-jobs/{job_id} — 获取分类任务详情
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实路由模块,mock Celery 和 repository。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
# mock celery_app 以避免实际发送任务
|
||||
import app.api.routes.classification_jobs as classification_routes
|
||||
from app.api.routes.classification_jobs import router
|
||||
from app.dependencies import get_classification_job_repository
|
||||
|
||||
from packages.adapters.in_memory import InMemoryClassificationJobRepository
|
||||
from packages.domain import ClassificationJob, ClassificationJobStatus
|
||||
|
||||
classification_routes.celery_app = MagicMock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_job(
|
||||
project_id: str = "proj-1",
|
||||
asset_id: str = "asset-1",
|
||||
status: ClassificationJobStatus = ClassificationJobStatus.PENDING,
|
||||
) -> ClassificationJob:
|
||||
job = ClassificationJob.create(project_id=project_id, asset_id=asset_id)
|
||||
if status == ClassificationJobStatus.PROCESSING:
|
||||
job.status = ClassificationJobStatus.PROCESSING
|
||||
elif status == ClassificationJobStatus.COMPLETED:
|
||||
job.status = ClassificationJobStatus.COMPLETED
|
||||
job.classification = "scenic"
|
||||
job.confidence = 0.92
|
||||
elif status == ClassificationJobStatus.FAILED:
|
||||
job.status = ClassificationJobStatus.FAILED
|
||||
job.error_message = "AI 服务不可用"
|
||||
return job
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemoryClassificationJobRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(repo):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/classification-jobs")
|
||||
|
||||
def _override_repo():
|
||||
return repo
|
||||
|
||||
test_app.dependency_overrides[get_classification_job_repository] = _override_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST / — 提交分类任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubmitClassificationJob:
|
||||
"""提交分类任务端点测试。"""
|
||||
|
||||
def test_submit_with_valid_data(self, client):
|
||||
"""使用有效数据提交分类任务应成功。"""
|
||||
resp = client.post(
|
||||
"/classification-jobs",
|
||||
json={
|
||||
"project_id": "proj-123",
|
||||
"asset_id": "asset-456",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["project_id"] == "proj-123"
|
||||
assert data["asset_id"] == "asset-456"
|
||||
assert data["status"] == "pending"
|
||||
assert data["classification"] == ""
|
||||
assert data["confidence"] == 0.0
|
||||
assert data["error_message"] == ""
|
||||
assert "id" in data
|
||||
assert len(data["id"]) > 0
|
||||
|
||||
def test_submit_generates_unique_id(self, client):
|
||||
"""每次提交应生成不同的任务 ID。"""
|
||||
resp1 = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
|
||||
resp2 = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a2"})
|
||||
assert resp1.json()["id"] != resp2.json()["id"]
|
||||
|
||||
def test_submit_missing_project_id_returns_422(self, client):
|
||||
"""缺少 project_id 应返回 422。"""
|
||||
resp = client.post("/classification-jobs", json={"asset_id": "asset-1"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_missing_asset_id_returns_422(self, client):
|
||||
"""缺少 asset_id 应返回 422。"""
|
||||
resp = client.post("/classification-jobs", json={"project_id": "proj-1"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_empty_project_id_returns_422(self, client):
|
||||
"""空 project_id 应返回 422。"""
|
||||
resp = client.post("/classification-jobs", json={"project_id": "", "asset_id": "asset-1"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_empty_asset_id_returns_422(self, client):
|
||||
"""空 asset_id 应返回 422。"""
|
||||
resp = client.post("/classification-jobs", json={"project_id": "proj-1", "asset_id": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_sends_celery_task(self, client):
|
||||
"""提交任务后应触发 Celery 异步任务。"""
|
||||
classification_routes.celery_app.send_task.reset_mock()
|
||||
|
||||
resp = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
job_id = resp.json()["id"]
|
||||
classification_routes.celery_app.send_task.assert_called_once_with(
|
||||
"worker.classify_asset",
|
||||
args=[job_id],
|
||||
)
|
||||
|
||||
def test_submit_persists_to_repository(self, client, repo):
|
||||
"""提交后任务应保存到 repository。"""
|
||||
resp = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
|
||||
job_id = resp.json()["id"]
|
||||
|
||||
saved = repo.get(job_id)
|
||||
assert saved is not None
|
||||
assert saved.project_id == "p1"
|
||||
assert saved.asset_id == "a1"
|
||||
assert saved.status == ClassificationJobStatus.PENDING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /{job_id} — 获取分类任务详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetClassificationJob:
|
||||
"""获取分类任务详情端点测试。"""
|
||||
|
||||
def test_get_pending_job(self, client, repo):
|
||||
"""获取 pending 状态的任务。"""
|
||||
job = _make_job(status=ClassificationJobStatus.PENDING)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/classification-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == job.id
|
||||
assert data["status"] == "pending"
|
||||
assert data["classification"] == ""
|
||||
assert data["confidence"] == 0.0
|
||||
|
||||
def test_get_processing_job(self, client, repo):
|
||||
"""获取 processing 状态的任务。"""
|
||||
job = _make_job(status=ClassificationJobStatus.PROCESSING)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/classification-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "processing"
|
||||
|
||||
def test_get_completed_job(self, client, repo):
|
||||
"""获取已完成的任务应包含分类结果和置信度。"""
|
||||
job = _make_job(status=ClassificationJobStatus.COMPLETED)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/classification-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["classification"] == "scenic"
|
||||
assert data["confidence"] == 0.92
|
||||
assert data["error_message"] == ""
|
||||
|
||||
def test_get_failed_job(self, client, repo):
|
||||
"""获取失败的任务应包含错误信息。"""
|
||||
job = _make_job(status=ClassificationJobStatus.FAILED)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/classification-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "AI 服务不可用" in data["error_message"]
|
||||
|
||||
def test_get_nonexistent_job_returns_404(self, client):
|
||||
"""获取不存在的任务应返回 404。"""
|
||||
resp = client.get("/classification-jobs/nonexistent-job-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_response_contains_all_required_fields(self, client, repo):
|
||||
"""响应应包含所有必需字段。"""
|
||||
job = _make_job()
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/classification-jobs/{job.id}")
|
||||
data = resp.json()
|
||||
for field in ["id", "project_id", "asset_id", "status", "classification", "confidence", "error_message"]:
|
||||
assert field in data, f"缺少字段: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClassificationApiScenarios:
|
||||
"""分类任务 API 跨端点集成场景。"""
|
||||
|
||||
def test_submit_then_get_pending(self, client, repo):
|
||||
"""提交任务后立即查询应为 pending 状态。"""
|
||||
submit_resp = client.post(
|
||||
"/classification-jobs",
|
||||
json={"project_id": "proj-scenario", "asset_id": "asset-scenario"},
|
||||
)
|
||||
assert submit_resp.status_code == 200
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
get_resp = client.get(f"/classification-jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["status"] == "pending"
|
||||
assert get_resp.json()["project_id"] == "proj-scenario"
|
||||
assert get_resp.json()["asset_id"] == "asset-scenario"
|
||||
|
||||
def test_submit_simulate_complete_then_get(self, client, repo):
|
||||
"""模拟 worker 完成任务后查询应返回结果。"""
|
||||
submit_resp = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
# 模拟 worker 处理完成
|
||||
job = repo.get(job_id)
|
||||
assert job is not None
|
||||
job.status = ClassificationJobStatus.COMPLETED
|
||||
job.classification = "product"
|
||||
job.confidence = 0.88
|
||||
repo.update(job)
|
||||
|
||||
# 查询结果
|
||||
get_resp = client.get(f"/classification-jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["classification"] == "product"
|
||||
assert data["confidence"] == 0.88
|
||||
|
||||
def test_submit_simulate_failure_then_get(self, client, repo):
|
||||
"""模拟 worker 失败后查询应返回错误信息。"""
|
||||
submit_resp = client.post("/classification-jobs", json={"project_id": "p1", "asset_id": "a1"})
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
# 模拟处理失败
|
||||
job = repo.get(job_id)
|
||||
assert job is not None
|
||||
job.status = ClassificationJobStatus.FAILED
|
||||
job.error_message = "网络超时"
|
||||
repo.update(job)
|
||||
|
||||
get_resp = client.get(f"/classification-jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "网络超时" in data["error_message"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,496 +0,0 @@
|
||||
"""
|
||||
仪表盘 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- GET /dashboard/overview — 仪表盘概览
|
||||
|
||||
验证返回数据结构、空数据场景、数据汇总正确性。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from app.api.routes.dashboard import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
get_title_library_repository,
|
||||
get_voice_library_repository,
|
||||
)
|
||||
|
||||
from packages.domain.entities import Project, User
|
||||
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryProjectRepository:
|
||||
def __init__(self):
|
||||
self._projects: dict[str, Project] = {}
|
||||
|
||||
def save(self, project: Project) -> None:
|
||||
self._projects[project.id] = project
|
||||
|
||||
def find_by_id(self, project_id: str):
|
||||
return self._projects.get(project_id)
|
||||
|
||||
def find_by_owner_user_id(self, owner_user_id: str):
|
||||
return [p for p in self._projects.values() if p.owner_user_id == owner_user_id]
|
||||
|
||||
def find_accessible_projects(self, user_id: str):
|
||||
return [p for p in self._projects.values() if p.owner_user_id == user_id]
|
||||
|
||||
def count_by_owner(self, owner_user_id: str) -> int:
|
||||
return len(self.find_by_owner_user_id(owner_user_id))
|
||||
|
||||
def delete(self, project_id: str) -> bool:
|
||||
if project_id in self._projects:
|
||||
del self._projects[project_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class InMemoryAssetRepository:
|
||||
def __init__(self):
|
||||
self._assets = []
|
||||
|
||||
def add_asset(self, project_id: str, storage_size: int = 0):
|
||||
self._assets.append({"project_id": project_id, "storage_size": storage_size})
|
||||
|
||||
def count_by_project_ids(self, project_ids: list[str]) -> int:
|
||||
return sum(1 for a in self._assets if a["project_id"] in project_ids)
|
||||
|
||||
def sum_storage_by_project_ids(self, project_ids: list[str]) -> int:
|
||||
return sum(a["storage_size"] for a in self._assets if a["project_id"] in project_ids)
|
||||
|
||||
# 其他方法占位
|
||||
def create(self, asset):
|
||||
return asset
|
||||
|
||||
def find_by_id(self, asset_id):
|
||||
return None
|
||||
|
||||
def find_by_project(self, project_id, **kwargs):
|
||||
return []
|
||||
|
||||
def find_by_library(self, library_id, **kwargs):
|
||||
return []
|
||||
|
||||
def update(self, asset):
|
||||
return asset
|
||||
|
||||
def delete(self, asset_id):
|
||||
return False
|
||||
|
||||
def batch_delete(self, asset_ids):
|
||||
return 0
|
||||
|
||||
def search_candidates(self, **kwargs):
|
||||
return []
|
||||
|
||||
def find_by_tag_ids(self, tag_ids):
|
||||
return []
|
||||
|
||||
def count_by_project(self, project_id):
|
||||
return 0
|
||||
|
||||
def find_by_library_and_file_type(self, library_id, file_type):
|
||||
return []
|
||||
|
||||
def find_by_library_and_file_hash(self, library_id, file_hash):
|
||||
return None
|
||||
|
||||
|
||||
class InMemoryGenerationTaskRepository:
|
||||
def __init__(self):
|
||||
self._tasks = {}
|
||||
|
||||
def add_task(self, task: GenerationTask):
|
||||
self._tasks[task.id] = task
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list:
|
||||
user_tasks = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
# 按 created_at 倒序
|
||||
user_tasks.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return user_tasks[:limit]
|
||||
|
||||
# 其他方法占位
|
||||
def create(self, task):
|
||||
return task
|
||||
|
||||
def get(self, task_id):
|
||||
return None
|
||||
|
||||
def list_by_project(self, project_id):
|
||||
return []
|
||||
|
||||
def list_by_user(self, user_id):
|
||||
return []
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id):
|
||||
return []
|
||||
|
||||
def update(self, task):
|
||||
return task
|
||||
|
||||
|
||||
class InMemoryTitleLibraryRepository:
|
||||
def __init__(self):
|
||||
self._items = {}
|
||||
|
||||
def add_item(self, user_id: str):
|
||||
from uuid import uuid4
|
||||
|
||||
item_id = uuid4().hex
|
||||
self._items[item_id] = {"id": item_id, "user_id": user_id}
|
||||
return item_id
|
||||
|
||||
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
|
||||
return len([i for i in self._items.values() if i["user_id"] == user_id])
|
||||
|
||||
# 其他方法占位
|
||||
def list_by_user(self, user_id, **kwargs):
|
||||
return []
|
||||
|
||||
def get(self, title_id, user_id):
|
||||
return None
|
||||
|
||||
def create(self, item):
|
||||
return item
|
||||
|
||||
def update(self, item):
|
||||
return item
|
||||
|
||||
def delete(self, title_id, user_id):
|
||||
return False
|
||||
|
||||
|
||||
class InMemoryVoiceLibraryRepository:
|
||||
def __init__(self):
|
||||
self._items = {}
|
||||
|
||||
def add_item(self, user_id: str):
|
||||
from uuid import uuid4
|
||||
|
||||
item_id = uuid4().hex
|
||||
self._items[item_id] = {"id": item_id, "user_id": user_id}
|
||||
return item_id
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([i for i in self._items.values() if i["user_id"] == user_id])
|
||||
|
||||
# 其他方法占位
|
||||
def list_by_user(self, user_id, **kwargs):
|
||||
return []
|
||||
|
||||
def get(self, voice_id, user_id):
|
||||
return None
|
||||
|
||||
def create(self, item):
|
||||
return item
|
||||
|
||||
def update(self, item):
|
||||
return item
|
||||
|
||||
def delete(self, voice_id, user_id):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_project(project_id: str, owner_user_id: str = "user-test-001") -> Project:
|
||||
return Project(
|
||||
id=project_id,
|
||||
name=f"Project {project_id}",
|
||||
owner_user_id=owner_user_id,
|
||||
description="",
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _make_generation_task(
|
||||
task_id: str,
|
||||
user_id: str = "user-test-001",
|
||||
status: GenerationTaskStatus = GenerationTaskStatus.COMPLETED,
|
||||
created_at: datetime | None = None,
|
||||
) -> GenerationTask:
|
||||
return GenerationTask(
|
||||
id=task_id,
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
created_by_user_id=user_id,
|
||||
status=status,
|
||||
error_message="",
|
||||
created_at=created_at or datetime.now(timezone.utc),
|
||||
started_at=datetime.now(timezone.utc) if status != GenerationTaskStatus.PENDING else None,
|
||||
completed_at=datetime.now(timezone.utc) if status == GenerationTaskStatus.COMPLETED else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_repo():
|
||||
repo = InMemoryProjectRepository()
|
||||
repo.save(_make_project("proj-1", "user-test-001"))
|
||||
repo.save(_make_project("proj-2", "user-test-001"))
|
||||
repo.save(_make_project("proj-other", "other-user"))
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asset_repo():
|
||||
return InMemoryAssetRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def generation_task_repo():
|
||||
return InMemoryGenerationTaskRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def title_library_repo():
|
||||
return InMemoryTitleLibraryRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voice_library_repo():
|
||||
return InMemoryVoiceLibraryRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/dashboard")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
|
||||
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
|
||||
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /overview — 仪表盘概览
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDashboardOverview:
|
||||
"""仪表盘概览端点测试。"""
|
||||
|
||||
def test_empty_data_returns_zeros(self, client):
|
||||
"""空数据时所有计数为 0。"""
|
||||
resp = client.get("/dashboard/overview")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["total_assets"] == 0
|
||||
assert data["used_storage_bytes"] == 0
|
||||
assert data["total_titles"] == 0
|
||||
assert data["total_voices"] == 0
|
||||
assert data["total_tasks"] == 0
|
||||
assert data["total_products"] == 2 # fixture 中有 2 个项目
|
||||
assert data["recent_tasks"] == []
|
||||
|
||||
def test_assets_count_and_storage(self, client, asset_repo):
|
||||
"""素材统计正确。"""
|
||||
asset_repo.add_asset("proj-1", 1024)
|
||||
asset_repo.add_asset("proj-1", 2048)
|
||||
asset_repo.add_asset("proj-2", 4096)
|
||||
# 其他用户的不计入
|
||||
asset_repo.add_asset("proj-other", 9999)
|
||||
|
||||
resp = client.get("/dashboard/overview")
|
||||
data = resp.json()
|
||||
|
||||
assert data["total_assets"] == 3
|
||||
assert data["used_storage_bytes"] == 1024 + 2048 + 4096
|
||||
|
||||
def test_title_library_count(self, client, title_library_repo):
|
||||
"""标题库统计正确。"""
|
||||
title_library_repo.add_item("user-test-001")
|
||||
title_library_repo.add_item("user-test-001")
|
||||
title_library_repo.add_item("user-test-001")
|
||||
title_library_repo.add_item("other-user")
|
||||
|
||||
resp = client.get("/dashboard/overview")
|
||||
data = resp.json()
|
||||
|
||||
assert data["total_titles"] == 3
|
||||
|
||||
def test_voice_library_count(self, client, voice_library_repo):
|
||||
"""配音库统计正确。"""
|
||||
voice_library_repo.add_item("user-test-001")
|
||||
voice_library_repo.add_item("other-user")
|
||||
|
||||
resp = client.get("/dashboard/overview")
|
||||
data = resp.json()
|
||||
|
||||
assert data["total_voices"] == 1
|
||||
|
||||
def test_generation_tasks_count(self, client, generation_task_repo):
|
||||
"""生成任务统计正确。"""
|
||||
generation_task_repo.add_task(_make_generation_task("task-1"))
|
||||
generation_task_repo.add_task(_make_generation_task("task-2"))
|
||||
generation_task_repo.add_task(_make_generation_task("task-other", user_id="other-user"))
|
||||
|
||||
resp = client.get("/dashboard/overview")
|
||||
data = resp.json()
|
||||
|
||||
assert data["total_tasks"] == 2
|
||||
|
||||
def test_recent_tasks_limited_to_5(self, client, generation_task_repo):
|
||||
"""最近任务最多返回 5 个。"""
|
||||
for i in range(10):
|
||||
task = _make_generation_task(f"task-{i}")
|
||||
generation_task_repo.add_task(task)
|
||||
|
||||
resp = client.get("/dashboard/overview")
|
||||
data = resp.json()
|
||||
|
||||
assert len(data["recent_tasks"]) <= 5
|
||||
|
||||
def test_recent_tasks_have_correct_fields(self, client, generation_task_repo):
|
||||
"""最近任务包含正确字段。"""
|
||||
task = _make_generation_task("task-1", status=GenerationTaskStatus.COMPLETED)
|
||||
generation_task_repo.add_task(task)
|
||||
|
||||
resp = client.get("/dashboard/overview")
|
||||
data = resp.json()
|
||||
|
||||
assert len(data["recent_tasks"]) == 1
|
||||
item = data["recent_tasks"][0]
|
||||
for field in ["id", "task_type", "status", "current_step", "error_message", "updated_at"]:
|
||||
assert field in item, f"缺少字段: {field}"
|
||||
assert item["task_type"] == "generation"
|
||||
|
||||
def test_subscription_info(self, client):
|
||||
"""订阅信息正确。"""
|
||||
resp = client.get("/dashboard/overview")
|
||||
data = resp.json()
|
||||
|
||||
assert "subscription" in data
|
||||
sub = data["subscription"]
|
||||
assert "plan" in sub
|
||||
assert "is_active" in sub
|
||||
assert sub["plan"] == "free"
|
||||
assert sub["is_active"] is True
|
||||
|
||||
def test_pro_user_subscription(
|
||||
self, project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo
|
||||
):
|
||||
"""Pro 用户订阅信息正确。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/dashboard")
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(
|
||||
user=_make_user(subscription_plan="pro", subscription_status="active")
|
||||
)
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
|
||||
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
|
||||
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
|
||||
|
||||
c = TestClient(test_app)
|
||||
resp = c.get("/dashboard/overview")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["subscription"]["plan"] == "pro"
|
||||
assert resp.json()["subscription"]["is_active"] is True
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_total_products_count(self, client, project_repo):
|
||||
"""项目(产品)数量正确。"""
|
||||
resp = client.get("/dashboard/overview")
|
||||
data = resp.json()
|
||||
assert data["total_products"] == 2
|
||||
|
||||
# 新增一个项目后
|
||||
project_repo.save(_make_project("proj-3", "user-test-001"))
|
||||
resp2 = client.get("/dashboard/overview")
|
||||
assert resp2.json()["total_products"] == 3
|
||||
|
||||
def test_unauthorized_returns_401(
|
||||
self, project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo
|
||||
):
|
||||
"""未授权访问返回 401/403。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/dashboard")
|
||||
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo
|
||||
test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo
|
||||
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
|
||||
|
||||
c = TestClient(test_app)
|
||||
resp = c.get("/dashboard/overview")
|
||||
assert resp.status_code in (401, 403)
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_recent_tasks_status_mapping(self, client, generation_task_repo):
|
||||
"""不同状态的任务显示正确的当前步骤。"""
|
||||
# 已完成任务
|
||||
completed_task = _make_generation_task("task-completed", status=GenerationTaskStatus.COMPLETED)
|
||||
generation_task_repo.add_task(completed_task)
|
||||
|
||||
resp = client.get("/dashboard/overview")
|
||||
tasks = resp.json()["recent_tasks"]
|
||||
completed = [t for t in tasks if t["id"] == "task-completed"][0]
|
||||
assert completed["status"] == "completed"
|
||||
assert "完成" in completed["current_step"] or "completed" in completed["current_step"].lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,554 +0,0 @@
|
||||
"""
|
||||
生成视频管理 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- GET /generated-videos — 列出生成视频
|
||||
- GET /generated-videos/{video_id} — 获取生成视频详情
|
||||
- PATCH /generated-videos/{video_id}/review — 更新审核状态
|
||||
- GET /generated-videos/{video_id}/download-url — 获取下载地址
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实路由模块,mock 所有外部依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from app.api.routes.generated_videos import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import get_generated_video_repository, get_project_repository
|
||||
|
||||
from packages.domain.entities import Project, User
|
||||
from packages.domain.generated_video import GeneratedVideo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository + 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryGeneratedVideoRepository:
|
||||
"""内存中的生成视频 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: dict[str, GeneratedVideo] = {}
|
||||
|
||||
def create(self, video: GeneratedVideo) -> GeneratedVideo:
|
||||
self._items[video.id] = video
|
||||
return video
|
||||
|
||||
def get(self, video_id: str) -> GeneratedVideo | None:
|
||||
return self._items.get(video_id)
|
||||
|
||||
def update(self, video: GeneratedVideo) -> GeneratedVideo:
|
||||
self._items[video.id] = video
|
||||
return video
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
|
||||
return [v for v in self._items.values() if v.project_id == project_id]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
return [v for v in self._items.values() if v.generation_task_id == generation_task_id]
|
||||
|
||||
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
|
||||
return []
|
||||
|
||||
|
||||
class InMemoryProjectRepository:
|
||||
"""内存中的项目 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._projects: dict[str, Project] = {}
|
||||
|
||||
def save(self, project: Project) -> None:
|
||||
self._projects[project.id] = project
|
||||
|
||||
def find_by_id(self, project_id: str) -> Project | None:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]:
|
||||
return [p for p in self._projects.values() if p.owner_user_id == owner_user_id]
|
||||
|
||||
def find_accessible_projects(self, user_id: str) -> list[Project]:
|
||||
return [p for p in self._projects.values() if p.owner_user_id == user_id]
|
||||
|
||||
def count_by_owner(self, owner_user_id: str) -> int:
|
||||
return len(self.find_by_owner_user_id(owner_user_id))
|
||||
|
||||
def delete(self, project_id: str) -> bool:
|
||||
if project_id in self._projects:
|
||||
del self._projects[project_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MockStorageService:
|
||||
"""Mock OSS 存储服务。"""
|
||||
|
||||
def get_download_url(self, file_url: str) -> str:
|
||||
return f"https://cdn.example.com/download/{file_url}?token=abc123"
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_project(project_id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
|
||||
return Project(
|
||||
id=project_id,
|
||||
name=f"Project {project_id}",
|
||||
owner_user_id=owner_user_id,
|
||||
description="",
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _make_video(
|
||||
project_id: str = "proj-1",
|
||||
name: str = "output.mp4",
|
||||
status: str = "completed",
|
||||
review_status: str = "pending_review",
|
||||
**kwargs,
|
||||
) -> GeneratedVideo:
|
||||
return GeneratedVideo.create(
|
||||
project_id=project_id,
|
||||
generation_task_id=kwargs.pop("generation_task_id", "task-1"),
|
||||
name=name,
|
||||
file_url=kwargs.pop("file_url", f"generated/{name}"),
|
||||
file_size=kwargs.pop("file_size", 1024000),
|
||||
duration=kwargs.pop("duration", 30.5),
|
||||
width=kwargs.pop("width", 1920),
|
||||
height=kwargs.pop("height", 1080),
|
||||
fps=kwargs.pop("fps", 30.0),
|
||||
thumbnail_url=kwargs.pop("thumbnail_url", None),
|
||||
generation_params=kwargs.pop("generation_params", {"resolution": "1080p"}),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video_repo():
|
||||
return InMemoryGeneratedVideoRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_repo():
|
||||
repo = InMemoryProjectRepository()
|
||||
# 默认创建一个项目
|
||||
repo.save(_make_project("proj-1", "user-test-001"))
|
||||
repo.save(_make_project("proj-2", "user-test-001"))
|
||||
repo.save(_make_project("proj-other", "other-user"))
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage_service():
|
||||
return MockStorageService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(video_repo, project_repo, storage_service):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/generated-videos")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
def _override_video_repo():
|
||||
return video_repo
|
||||
|
||||
def _override_project_repo():
|
||||
return project_repo
|
||||
|
||||
def _override_storage():
|
||||
return storage_service
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_generated_video_repository] = _override_video_repo
|
||||
test_app.dependency_overrides[get_project_repository] = _override_project_repo
|
||||
test_app.dependency_overrides[get_storage_service] = _override_storage
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. GET / — 列出生成视频
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListGeneratedVideos:
|
||||
"""列出生成视频端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无视频时返回空列表。"""
|
||||
resp = client.get("/generated-videos")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
|
||||
def test_list_all_user_videos(self, client, video_repo, project_repo):
|
||||
"""列出当前用户所有项目的视频。"""
|
||||
v1 = _make_video(project_id="proj-1", name="video1.mp4")
|
||||
v2 = _make_video(project_id="proj-2", name="video2.mp4")
|
||||
v3 = _make_video(project_id="proj-other", name="other.mp4") # 其他用户
|
||||
video_repo.create(v1)
|
||||
video_repo.create(v2)
|
||||
video_repo.create(v3)
|
||||
|
||||
resp = client.get("/generated-videos")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
names = {item["name"] for item in data["items"]}
|
||||
assert names == {"video1.mp4", "video2.mp4"}
|
||||
|
||||
def test_filter_by_project_id(self, client, video_repo):
|
||||
"""按 project_id 筛选视频。"""
|
||||
v1 = _make_video(project_id="proj-1", name="a.mp4")
|
||||
v2 = _make_video(project_id="proj-2", name="b.mp4")
|
||||
video_repo.create(v1)
|
||||
video_repo.create(v2)
|
||||
|
||||
resp = client.get("/generated-videos?project_id=proj-1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["name"] == "a.mp4"
|
||||
|
||||
def test_filter_by_nonexistent_project_returns_404(self, client):
|
||||
"""筛选不存在的项目返回 404。"""
|
||||
resp = client.get("/generated-videos?project_id=nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_list_includes_download_url(self, client, video_repo):
|
||||
"""列表响应应包含下载地址。"""
|
||||
v = _make_video(file_url="generated/test.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get("/generated-videos")
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert "download_url" in item
|
||||
assert item["download_url"] is not None
|
||||
assert "cdn.example.com" in item["download_url"]
|
||||
|
||||
def test_list_response_fields(self, client, video_repo):
|
||||
"""列表响应包含所有必需字段。"""
|
||||
v = _make_video()
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get("/generated-videos")
|
||||
item = resp.json()["items"][0]
|
||||
for field in [
|
||||
"id",
|
||||
"project_id",
|
||||
"generation_task_id",
|
||||
"name",
|
||||
"file_url",
|
||||
"file_size",
|
||||
"duration",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"status",
|
||||
"review_status",
|
||||
"generation_params",
|
||||
"download_url",
|
||||
]:
|
||||
assert field in item, f"缺少字段: {field}"
|
||||
|
||||
def test_unauthorized_returns_401(self, video_repo, project_repo, storage_service):
|
||||
"""未授权访问返回 401/403。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/generated-videos")
|
||||
|
||||
# 不覆盖 get_current_user,使用默认(会拒绝无 token 请求)
|
||||
test_app.dependency_overrides[get_generated_video_repository] = lambda: video_repo
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_storage_service] = lambda: storage_service
|
||||
|
||||
c = TestClient(test_app)
|
||||
resp = c.get("/generated-videos")
|
||||
# 无 token 时 fastapi HTTPBearer auto_error=False 会返回 None,
|
||||
# get_current_user 会抛 401
|
||||
assert resp.status_code in (401, 403)
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /{video_id} — 获取生成视频详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetGeneratedVideo:
|
||||
"""获取生成视频详情端点测试。"""
|
||||
|
||||
def test_get_existing_video(self, client, video_repo):
|
||||
"""获取存在的视频返回详情。"""
|
||||
v = _make_video(name="detail.mp4", duration=45.0)
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == v.id
|
||||
assert data["name"] == "detail.mp4"
|
||||
assert data["duration"] == 45.0
|
||||
assert data["status"] == "completed"
|
||||
|
||||
def test_get_includes_download_url(self, client, video_repo):
|
||||
"""详情响应包含下载地址。"""
|
||||
v = _make_video(file_url="generated/detail.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}")
|
||||
data = resp.json()
|
||||
assert "download_url" in data
|
||||
assert "cdn.example.com" in data["download_url"]
|
||||
|
||||
def test_get_nonexistent_returns_404(self, client):
|
||||
"""获取不存在的视频返回 404。"""
|
||||
resp = client.get("/generated-videos/nonexistent-video-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
def test_get_thumbnail_url(self, client, video_repo):
|
||||
"""有缩略图时返回缩略图 URL。"""
|
||||
v = _make_video(thumbnail_url="thumbs/test.jpg")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}")
|
||||
data = resp.json()
|
||||
assert data["thumbnail_url"] == "thumbs/test.jpg"
|
||||
|
||||
def test_get_generation_params(self, client, video_repo):
|
||||
"""返回生成参数。"""
|
||||
params = {"resolution": "4k", "style": "cinematic"}
|
||||
v = _make_video(generation_params=params)
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}")
|
||||
data = resp.json()
|
||||
assert data["generation_params"]["resolution"] == "4k"
|
||||
assert data["generation_params"]["style"] == "cinematic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. PATCH /{video_id}/review — 更新审核状态
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateReviewStatus:
|
||||
"""更新审核状态端点测试。"""
|
||||
|
||||
def test_approve_video(self, client, video_repo):
|
||||
"""审核通过。"""
|
||||
v = _make_video(review_status="pending_review")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "approved"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["review_status"] == "approved"
|
||||
|
||||
# 验证 repository 已更新
|
||||
updated = video_repo.get(v.id)
|
||||
assert updated.review_status == "approved"
|
||||
|
||||
def test_reject_video(self, client, video_repo):
|
||||
"""审核拒绝。"""
|
||||
v = _make_video(review_status="pending_review")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "rejected"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["review_status"] == "rejected"
|
||||
|
||||
def test_set_pending_review(self, client, video_repo):
|
||||
"""设置为待审核。"""
|
||||
v = _make_video(review_status="approved")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "pending_review"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["review_status"] == "pending_review"
|
||||
|
||||
def test_nonexistent_video_returns_404(self, client):
|
||||
"""更新不存在的视频返回 404。"""
|
||||
resp = client.patch(
|
||||
"/nonexistent-id/review",
|
||||
json={"review_status": "approved"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_invalid_status_returns_422(self, client, video_repo):
|
||||
"""无效审核状态返回 422。"""
|
||||
v = _make_video()
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "invalid_status"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_missing_status_returns_422(self, client, video_repo):
|
||||
"""缺少 review_status 字段返回 422。"""
|
||||
v = _make_video()
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(f"/generated-videos/{v.id}/review", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_returns_updated_fields(self, client, video_repo):
|
||||
"""更新后返回完整的视频信息。"""
|
||||
v = _make_video(name="review_test.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "approved"},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["name"] == "review_test.mp4"
|
||||
assert "id" in data
|
||||
assert "download_url" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. GET /{video_id}/download-url — 获取下载地址
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetDownloadUrl:
|
||||
"""获取下载地址端点测试。"""
|
||||
|
||||
def test_get_download_url_success(self, client, video_repo):
|
||||
"""获取下载地址成功。"""
|
||||
v = _make_video(file_url="generated/video.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}/download-url")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["video_id"] == v.id
|
||||
assert "download_url" in data
|
||||
assert "cdn.example.com" in data["download_url"]
|
||||
|
||||
def test_nonexistent_video_returns_404(self, client):
|
||||
"""获取不存在视频的下载地址返回 404。"""
|
||||
resp = client.get("/generated-videos/nonexistent-id/download-url")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_download_url_format(self, client, video_repo):
|
||||
"""下载地址格式正确。"""
|
||||
v = _make_video(file_url="my-video.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get(f"/generated-videos/{v.id}/download-url")
|
||||
url = resp.json()["download_url"]
|
||||
assert url.startswith("https://")
|
||||
assert "token=" in url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCrossEndpointScenarios:
|
||||
"""跨端点集成场景。"""
|
||||
|
||||
def test_create_list_detail_review_flow(self, client, video_repo):
|
||||
"""列表 → 详情 → 审核 完整流程。"""
|
||||
# 准备数据
|
||||
v = _make_video(name="flow.mp4", review_status="pending_review")
|
||||
video_repo.create(v)
|
||||
|
||||
# 1. 列表
|
||||
list_resp = client.get("/generated-videos")
|
||||
assert list_resp.status_code == 200
|
||||
assert len(list_resp.json()["items"]) == 1
|
||||
|
||||
# 2. 详情
|
||||
detail_resp = client.get(f"/generated-videos/{v.id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["name"] == "flow.mp4"
|
||||
assert detail_resp.json()["review_status"] == "pending_review"
|
||||
|
||||
# 3. 审核通过
|
||||
review_resp = client.patch(
|
||||
f"/generated-videos/{v.id}/review",
|
||||
json={"review_status": "approved"},
|
||||
)
|
||||
assert review_resp.status_code == 200
|
||||
assert review_resp.json()["review_status"] == "approved"
|
||||
|
||||
# 4. 再次查看详情确认
|
||||
detail_resp2 = client.get(f"/generated-videos/{v.id}")
|
||||
assert detail_resp2.json()["review_status"] == "approved"
|
||||
|
||||
# 5. 获取下载地址
|
||||
dl_resp = client.get(f"/generated-videos/{v.id}/download-url")
|
||||
assert dl_resp.status_code == 200
|
||||
assert dl_resp.json()["video_id"] == v.id
|
||||
|
||||
def test_multiple_videos_pagination_simulation(self, client, video_repo):
|
||||
"""多个视频时列表正确返回所有视频。"""
|
||||
for i in range(5):
|
||||
v = _make_video(project_id="proj-1", name=f"video_{i}.mp4")
|
||||
video_repo.create(v)
|
||||
|
||||
resp = client.get("/generated-videos")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()["items"]
|
||||
assert len(items) == 5
|
||||
names = {item["name"] for item in items}
|
||||
assert len(names) == 5 # 全部不同
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,614 +0,0 @@
|
||||
"""
|
||||
生成任务 API 集成测试
|
||||
|
||||
覆盖端点:
|
||||
- POST /generation/tasks — 创建生成任务
|
||||
- GET /generation/tasks — 列出生成任务
|
||||
- GET /generation/tasks/{task_id} — 获取生成任务详情
|
||||
- GET /generation/tasks/{task_id}/results — 列出生成结果
|
||||
- POST /generation/tasks/{task_id}/retry — 重试生成任务
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实模块,mock 外部依赖(Celery任务)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.generation_tasks import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
from packages.domain import (
|
||||
Asset,
|
||||
AssetLibrary,
|
||||
AssetLibraryKind,
|
||||
AssetStatus,
|
||||
ClassificationStatus,
|
||||
GeneratedVideo,
|
||||
GenerationTask,
|
||||
GenerationTaskStatus,
|
||||
Project,
|
||||
User,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Stub Repository 实现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects: dict[str, Project] | None = None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def find_by_id(self, project_id: str) -> Project | None:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
def find_accessible_projects(self, user_id: str) -> list[Project]:
|
||||
return [p for p in self._projects.values() if p.can_access(user_id)]
|
||||
|
||||
|
||||
class StubAssetLibraryRepository:
|
||||
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
|
||||
self._libraries = libraries or {}
|
||||
|
||||
def get(self, library_id: str) -> AssetLibrary | None:
|
||||
return self._libraries.get(library_id)
|
||||
|
||||
def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]:
|
||||
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
|
||||
if kind is not None:
|
||||
items = [lib for lib in items if lib.kind == kind]
|
||||
return items
|
||||
|
||||
|
||||
class StubAssetRepository:
|
||||
def __init__(self, assets: dict[str, Asset] | None = None):
|
||||
self._assets = assets or {}
|
||||
|
||||
def find_by_id(self, asset_id: str) -> Asset | None:
|
||||
return self._assets.get(asset_id)
|
||||
|
||||
def find_by_library(self, library_id: str, skip: int = 0, limit: int = 100) -> list[Asset]:
|
||||
return [a for a in self._assets.values() if a.library_id == library_id][skip : skip + limit]
|
||||
|
||||
|
||||
class StubGenerationTaskRepository:
|
||||
def __init__(self, tasks: dict[str, GenerationTask] | None = None):
|
||||
self._tasks = tasks or {}
|
||||
|
||||
def create(self, task: GenerationTask) -> GenerationTask:
|
||||
self._tasks[task.id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> GenerationTask | None:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if t.project_id == project_id]
|
||||
|
||||
def list_by_user(self, user_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
|
||||
def update(self, task: GenerationTask) -> GenerationTask:
|
||||
self._tasks[task.id] = task
|
||||
return task
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if getattr(t, "source_edit_plan_id", "") == plan_id]
|
||||
|
||||
|
||||
class StubGeneratedVideoRepository:
|
||||
def __init__(self, videos: dict[str, GeneratedVideo] | None = None):
|
||||
self._videos = videos or {}
|
||||
|
||||
def create(self, video: GeneratedVideo) -> GeneratedVideo:
|
||||
self._videos[video.id] = video
|
||||
return video
|
||||
|
||||
def get(self, video_id: str) -> GeneratedVideo | None:
|
||||
return self._videos.get(video_id)
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
|
||||
return [v for v in self._videos.values() if v.project_id == project_id]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
return [v for v in self._videos.values() if v.generation_task_id == generation_task_id]
|
||||
|
||||
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Helpers & Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
|
||||
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
|
||||
|
||||
|
||||
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
|
||||
return AssetLibrary(
|
||||
id=id,
|
||||
name="Generation Library",
|
||||
project_id=project_id,
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
)
|
||||
|
||||
|
||||
def _make_ready_asset(asset_id: str, library_id: str = "lib-1", project_id: str = "proj-1") -> Asset:
|
||||
return Asset(
|
||||
id=asset_id,
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
name=f"{asset_id}.mp4",
|
||||
storage_key=f"uploads/{asset_id}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
classification_status=ClassificationStatus.COMPLETED,
|
||||
duration=30.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
quality_score=80.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/api/v1/generation")
|
||||
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
# 预置一个 ready 状态的视频素材,用于创建生成任务
|
||||
asset = _make_ready_asset("asset-ready-1")
|
||||
|
||||
project_repo = StubProjectRepository({project.id: project})
|
||||
library_repo = StubAssetLibraryRepository({library.id: library})
|
||||
asset_repo = StubAssetRepository({asset.id: asset})
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
video_repo = StubGeneratedVideoRepository()
|
||||
|
||||
def _override_current_user():
|
||||
mock_auth = MagicMock(spec=AuthenticatedUser)
|
||||
mock_auth.user = _make_user()
|
||||
return mock_auth
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
|
||||
test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_generated_video_repository] = lambda: video_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST /tasks — 创建生成任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateGenerationTask:
|
||||
"""创建生成任务端点测试。"""
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_create_task_success(self, mock_celery, client):
|
||||
"""正常创建生成任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "strategy-default",
|
||||
"voice_library_id": "voice-lib-1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert len(data["items"]) == 1
|
||||
assert data["total"] == 1
|
||||
task = data["items"][0]
|
||||
assert task["project_id"] == "proj-1"
|
||||
assert task["status"] == "pending"
|
||||
assert task["progress"] == 0.0
|
||||
assert task["result_count"] == 0
|
||||
assert "id" in task
|
||||
# 验证 Celery 任务被发送
|
||||
assert mock_celery.send_task.called
|
||||
assert mock_celery.send_task.call_args[0][0] == "worker.generate_video"
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_create_batch_tasks(self, mock_celery, client):
|
||||
"""批量创建多个生成任务。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "strategy-default",
|
||||
"voice_library_id": "voice-lib-1",
|
||||
"count": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["total"] == 3
|
||||
# 验证所有任务都有不同的 ID
|
||||
task_ids = [t["id"] for t in data["items"]]
|
||||
assert len(set(task_ids)) == 3
|
||||
# 同一批次应有相同的 batch_id
|
||||
batch_ids = [t["batch_id"] for t in data["items"] if t["batch_id"]]
|
||||
assert len(batch_ids) == 3
|
||||
assert len(set(batch_ids)) == 1
|
||||
|
||||
def test_create_task_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "nonexistent",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "Project" in resp.json()["detail"]
|
||||
|
||||
def test_create_task_library_not_found(self, client):
|
||||
"""素材库不存在返回 404。"""
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "nonexistent",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "AssetLibrary" in resp.json()["detail"]
|
||||
|
||||
def test_create_task_missing_project_and_template(self, client):
|
||||
"""缺少 project_id 和 template_id 返回 422。"""
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /tasks — 列出生成任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListGenerationTasks:
|
||||
"""列出生成任务端点测试。"""
|
||||
|
||||
def _create_task(self, client, task_suffix: str = "1"):
|
||||
"""辅助方法:创建一个生成任务。"""
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": f"strategy-{task_suffix}",
|
||||
"voice_library_id": "voice-lib-1",
|
||||
},
|
||||
)
|
||||
return resp.json()["items"][0]["id"]
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无任务时返回空列表。"""
|
||||
resp = client.get("/api/v1/generation/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_list_returns_user_tasks(self, mock_celery, client):
|
||||
"""返回当前用户的生成任务列表。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
# 创建 2 个任务
|
||||
for i in range(2):
|
||||
client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": f"strat-{i}",
|
||||
"voice_library_id": "voice-1",
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get("/api/v1/generation/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
# 验证响应字段
|
||||
for item in data["items"]:
|
||||
assert "id" in item
|
||||
assert "status" in item
|
||||
assert "progress" in item
|
||||
assert "project_id" in item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. GET /tasks/{task_id} — 获取生成任务详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetGenerationTask:
|
||||
"""获取生成任务详情端点测试。"""
|
||||
|
||||
def _create_task(self, client) -> str:
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
return resp.json()["items"][0]["id"]
|
||||
|
||||
def test_get_task_success(self, client):
|
||||
"""获取存在的任务详情成功。"""
|
||||
task_id = self._create_task(client)
|
||||
|
||||
resp = client.get(f"/api/v1/generation/tasks/{task_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == task_id
|
||||
assert data["status"] == "pending"
|
||||
assert data["progress"] == 0.0
|
||||
assert data["result_count"] == 0
|
||||
assert "asset_ids" in data
|
||||
assert "strategy_id" in data
|
||||
|
||||
def test_get_nonexistent_task_returns_404(self, client):
|
||||
"""获取不存在的任务返回 404。"""
|
||||
resp = client.get("/api/v1/generation/tasks/nonexistent-task-id")
|
||||
assert resp.status_code == 404
|
||||
assert "GenerationTask" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. GET /tasks/{task_id}/results — 列出生成结果
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListGenerationResults:
|
||||
"""列出生成结果端点测试。"""
|
||||
|
||||
def _create_task(self, client) -> str:
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
return resp.json()["items"][0]["id"]
|
||||
|
||||
def test_empty_results(self, client):
|
||||
"""无生成结果时返回空列表。"""
|
||||
task_id = self._create_task(client)
|
||||
|
||||
resp = client.get(f"/api/v1/generation/tasks/{task_id}/results")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
def test_results_nonexistent_task_returns_404(self, client):
|
||||
"""查询不存在任务的结果返回 404。"""
|
||||
resp = client.get("/api/v1/generation/tasks/nonexistent-task/results")
|
||||
assert resp.status_code == 404
|
||||
assert "GenerationTask" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. POST /tasks/{task_id}/retry — 重试生成任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryGenerationTask:
|
||||
"""重试生成任务端点测试。"""
|
||||
|
||||
def _create_failed_task(self, client) -> str:
|
||||
"""创建一个失败状态的任务。"""
|
||||
with patch("app.api.routes.generation_tasks.celery_app") as mock_celery:
|
||||
mock_celery.send_task = MagicMock()
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
task_id = resp.json()["items"][0]["id"]
|
||||
|
||||
# 直接修改 repository 中的任务状态为 failed
|
||||
from app.dependencies import get_generation_task_repository
|
||||
|
||||
# 由于是 stub,我们需要通过另一种方式设置状态
|
||||
# 让我们直接通过 retry 测试来验证
|
||||
return task_id
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_retry_failed_task(self, mock_celery, client):
|
||||
"""重试失败的任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
# 先创建一个任务
|
||||
create_resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
task_id = create_resp.json()["items"][0]["id"]
|
||||
|
||||
# 手动将任务状态设为 failed(通过直接访问 repository)
|
||||
# 由于 repository 在 fixture 中创建,我们需要另一种方式
|
||||
# 这里我们测试:pending 状态的任务重试应返回 409
|
||||
resp = client.post(f"/api/v1/generation/tasks/{task_id}/retry")
|
||||
assert resp.status_code == 409
|
||||
assert "Only failed" in resp.json()["detail"]
|
||||
|
||||
def test_retry_nonexistent_task_returns_404(self, client):
|
||||
"""重试不存在的任务返回 404。"""
|
||||
resp = client.post("/api/v1/generation/tasks/nonexistent-task/retry")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_retry_completed_task_returns_409(self, mock_celery, client):
|
||||
"""重试已完成的任务返回 409。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
create_resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "s1",
|
||||
"voice_library_id": "v1",
|
||||
},
|
||||
)
|
||||
task_id = create_resp.json()["items"][0]["id"]
|
||||
|
||||
# pending 状态不是 failed,重试应返回 409
|
||||
resp = client.post(f"/api/v1/generation/tasks/{task_id}/retry")
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. 完整流程集成测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerationTaskFlow:
|
||||
"""生成任务完整流程集成测试。"""
|
||||
|
||||
@patch("app.api.routes.generation_tasks.celery_app")
|
||||
def test_create_list_detail_results_flow(self, mock_celery, client):
|
||||
"""测试创建 → 列表 → 详情 → 结果 完整流程。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
# 1. 创建任务
|
||||
create_resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "strategy-main",
|
||||
"voice_library_id": "voice-main",
|
||||
"count": 1,
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
task_id = create_resp.json()["items"][0]["id"]
|
||||
|
||||
# 2. 列表应包含新任务
|
||||
list_resp = client.get("/api/v1/generation/tasks")
|
||||
assert list_resp.status_code == 200
|
||||
assert any(t["id"] == task_id for t in list_resp.json()["items"])
|
||||
|
||||
# 3. 获取详情
|
||||
detail_resp = client.get(f"/api/v1/generation/tasks/{task_id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["id"] == task_id
|
||||
assert detail_resp.json()["status"] == "pending"
|
||||
|
||||
# 4. 获取结果(初始为空)
|
||||
results_resp = client.get(f"/api/v1/generation/tasks/{task_id}/results")
|
||||
assert results_resp.status_code == 200
|
||||
assert results_resp.json()["items"] == []
|
||||
|
||||
# 5. 验证 Celery worker 被调用
|
||||
assert mock_celery.send_task.called
|
||||
call_args = mock_celery.send_task.call_args
|
||||
assert call_args[0][0] == "worker.generate_video"
|
||||
assert call_args[1]["args"][0] == task_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,369 +0,0 @@
|
||||
"""
|
||||
摄入任务 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- POST /ingest-jobs — 提交摄入任务
|
||||
- GET /ingest-jobs/{job_id} — 获取摄入任务详情
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实路由模块,mock Celery 和 repository。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
# mock celery_app 以避免实际发送任务
|
||||
import app.api.routes.ingest_jobs as ingest_routes
|
||||
from app.api.routes.ingest_jobs import router
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
|
||||
from packages.adapters.in_memory import InMemoryIngestJobRepository
|
||||
from packages.domain import IngestJob, IngestJobStatus
|
||||
|
||||
ingest_routes.celery_app = MagicMock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_job(
|
||||
project_id: str = "proj-1",
|
||||
library_id: str = "lib-1",
|
||||
storage_key: str = "uploads/test.mp4",
|
||||
status: IngestJobStatus = IngestJobStatus.PENDING,
|
||||
) -> IngestJob:
|
||||
job = IngestJob.create(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
)
|
||||
if status == IngestJobStatus.PROCESSING:
|
||||
job.status = IngestJobStatus.PROCESSING
|
||||
elif status == IngestJobStatus.COMPLETED:
|
||||
job.status = IngestJobStatus.COMPLETED
|
||||
job.result_asset_id = "asset-completed-001"
|
||||
elif status == IngestJobStatus.FAILED:
|
||||
job.status = IngestJobStatus.FAILED
|
||||
job.error_message = "文件解析失败"
|
||||
return job
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemoryIngestJobRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(repo):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/ingest-jobs")
|
||||
|
||||
def _override_repo():
|
||||
return repo
|
||||
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = _override_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. POST / — 提交摄入任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubmitIngestJob:
|
||||
"""提交摄入任务端点测试。"""
|
||||
|
||||
def test_submit_with_valid_data(self, client):
|
||||
"""使用有效数据提交摄入任务应成功。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={
|
||||
"project_id": "proj-123",
|
||||
"library_id": "lib-456",
|
||||
"storage_key": "uploads/video.mp4",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["project_id"] == "proj-123"
|
||||
assert data["library_id"] == "lib-456"
|
||||
assert data["storage_key"] == "uploads/video.mp4"
|
||||
assert data["status"] == "pending"
|
||||
assert data["error_message"] == ""
|
||||
assert data["result_asset_id"] == "" or data["result_asset_id"] is None
|
||||
assert "id" in data
|
||||
assert len(data["id"]) > 0
|
||||
|
||||
def test_submit_generates_unique_id(self, client):
|
||||
"""每次提交应生成不同的任务 ID。"""
|
||||
resp1 = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "a.mp4"},
|
||||
)
|
||||
resp2 = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "b.mp4"},
|
||||
)
|
||||
assert resp1.json()["id"] != resp2.json()["id"]
|
||||
|
||||
def test_submit_missing_project_id_returns_422(self, client):
|
||||
"""缺少 project_id 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"library_id": "lib-1", "storage_key": "uploads/test.mp4"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_missing_library_id_returns_422(self, client):
|
||||
"""缺少 library_id 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "proj-1", "storage_key": "uploads/test.mp4"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_missing_storage_key_returns_422(self, client):
|
||||
"""缺少 storage_key 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "proj-1", "library_id": "lib-1"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_empty_project_id_returns_422(self, client):
|
||||
"""空 project_id 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "", "library_id": "lib-1", "storage_key": "x.mp4"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_empty_library_id_returns_422(self, client):
|
||||
"""空 library_id 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "", "storage_key": "x.mp4"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_empty_storage_key_returns_422(self, client):
|
||||
"""空 storage_key 应返回 422。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_submit_sends_celery_task(self, client):
|
||||
"""提交任务后应触发 Celery 异步任务。"""
|
||||
ingest_routes.celery_app.send_task.reset_mock()
|
||||
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "x.mp4"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
job_id = resp.json()["id"]
|
||||
ingest_routes.celery_app.send_task.assert_called_once_with(
|
||||
"worker.ingest_asset",
|
||||
args=[job_id],
|
||||
)
|
||||
|
||||
def test_submit_persists_to_repository(self, client, repo):
|
||||
"""提交后任务应保存到 repository。"""
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "test.mp4"},
|
||||
)
|
||||
job_id = resp.json()["id"]
|
||||
|
||||
saved = repo.get(job_id)
|
||||
assert saved is not None
|
||||
assert saved.project_id == "p1"
|
||||
assert saved.library_id == "l1"
|
||||
assert saved.storage_key == "test.mp4"
|
||||
assert saved.status == IngestJobStatus.PENDING
|
||||
|
||||
def test_submit_with_different_file_types(self, client):
|
||||
"""支持不同文件类型的 storage_key。"""
|
||||
for key in ["uploads/image.jpg", "videos/clip.mov", "audio/sound.mp3"]:
|
||||
resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": key},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["storage_key"] == key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /{job_id} — 获取摄入任务详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetIngestJob:
|
||||
"""获取摄入任务详情端点测试。"""
|
||||
|
||||
def test_get_pending_job(self, client, repo):
|
||||
"""获取 pending 状态的任务。"""
|
||||
job = _make_job(status=IngestJobStatus.PENDING)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == job.id
|
||||
assert data["status"] == "pending"
|
||||
assert data["result_asset_id"] == "" or data["result_asset_id"] is None
|
||||
|
||||
def test_get_processing_job(self, client, repo):
|
||||
"""获取 processing 状态的任务。"""
|
||||
job = _make_job(status=IngestJobStatus.PROCESSING)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "processing"
|
||||
|
||||
def test_get_completed_job(self, client, repo):
|
||||
"""获取已完成的任务应包含 result_asset_id。"""
|
||||
job = _make_job(status=IngestJobStatus.COMPLETED)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["result_asset_id"] == "asset-completed-001"
|
||||
assert data["error_message"] == ""
|
||||
|
||||
def test_get_failed_job(self, client, repo):
|
||||
"""获取失败的任务应包含错误信息。"""
|
||||
job = _make_job(status=IngestJobStatus.FAILED)
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "文件解析失败" in data["error_message"]
|
||||
|
||||
def test_get_nonexistent_job_raises_error(self, client):
|
||||
"""获取不存在的任务会抛出 ValueError(当前实现未使用 HTTPException)。"""
|
||||
# 注:路由中使用 raise ValueError 而非 HTTPException,
|
||||
# 在 TestClient 中会以异常形式抛出。生产环境会返回 500。
|
||||
# 此处验证当前行为:当 job 不存在时会报错。
|
||||
try:
|
||||
resp = client.get("/ingest-jobs/nonexistent-job-id")
|
||||
# 如果 FastAPI 捕获了异常,会返回 500
|
||||
assert resp.status_code == 500
|
||||
except (ValueError, Exception):
|
||||
# TestClient 中 ValueError 可能直接抛出
|
||||
pass # 符合预期:不存在的任务会报错
|
||||
|
||||
def test_response_contains_all_required_fields(self, client, repo):
|
||||
"""响应应包含所有必需字段。"""
|
||||
job = _make_job()
|
||||
repo.create(job)
|
||||
|
||||
resp = client.get(f"/ingest-jobs/{job.id}")
|
||||
data = resp.json()
|
||||
for field in ["id", "project_id", "library_id", "storage_key", "status", "error_message"]:
|
||||
assert field in data, f"缺少字段: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIngestApiScenarios:
|
||||
"""摄入任务 API 跨端点集成场景。"""
|
||||
|
||||
def test_submit_then_get_pending(self, client, repo):
|
||||
"""提交任务后立即查询应为 pending 状态。"""
|
||||
submit_resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={
|
||||
"project_id": "proj-scenario",
|
||||
"library_id": "lib-scenario",
|
||||
"storage_key": "uploads/scenario.mp4",
|
||||
},
|
||||
)
|
||||
assert submit_resp.status_code == 200
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
get_resp = client.get(f"/ingest-jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["status"] == "pending"
|
||||
assert get_resp.json()["storage_key"] == "uploads/scenario.mp4"
|
||||
|
||||
def test_submit_simulate_complete_then_get(self, client, repo):
|
||||
"""模拟 worker 完成任务后查询应返回 asset_id。"""
|
||||
submit_resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "video.mp4"},
|
||||
)
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
# 模拟 worker 处理完成
|
||||
job = repo.get(job_id)
|
||||
assert job is not None
|
||||
job.status = IngestJobStatus.COMPLETED
|
||||
job.result_asset_id = "asset-new-001"
|
||||
repo.update(job)
|
||||
|
||||
get_resp = client.get(f"/ingest-jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["result_asset_id"] == "asset-new-001"
|
||||
|
||||
def test_submit_simulate_failure_then_get(self, client, repo):
|
||||
"""模拟 worker 失败后查询应返回错误信息。"""
|
||||
submit_resp = client.post(
|
||||
"/ingest-jobs",
|
||||
json={"project_id": "p1", "library_id": "l1", "storage_key": "bad.mp4"},
|
||||
)
|
||||
job_id = submit_resp.json()["id"]
|
||||
|
||||
# 模拟处理失败
|
||||
job = repo.get(job_id)
|
||||
assert job is not None
|
||||
job.status = IngestJobStatus.FAILED
|
||||
job.error_message = "文件格式不支持"
|
||||
repo.update(job)
|
||||
|
||||
get_resp = client.get(f"/ingest-jobs/{job_id}")
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "文件格式不支持" in data["error_message"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,632 +0,0 @@
|
||||
"""
|
||||
任务中心 API 集成测试
|
||||
|
||||
覆盖端点:
|
||||
- GET /tasks — 列出用户任务
|
||||
- POST /tasks/{task_id}/retry — 重试用户任务
|
||||
- GET /projects/{project_id}/tasks — 列出项目任务
|
||||
- POST /tasks/{task_type}/{source_id}/retry — 重试项目任务
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
导入真实模块,mock 外部依赖(Celery任务)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.task_center import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_generation_task_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
from packages.domain import (
|
||||
GenerationTask,
|
||||
GenerationTaskStatus,
|
||||
IngestJob,
|
||||
IngestJobStatus,
|
||||
Project,
|
||||
User,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Stub Repository 实现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects: dict[str, Project] | None = None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def find_by_id(self, project_id: str) -> Project | None:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
class StubGenerationTaskRepository:
|
||||
def __init__(self, tasks: dict[str, GenerationTask] | None = None):
|
||||
self._tasks = tasks or {}
|
||||
|
||||
def create(self, task: GenerationTask) -> GenerationTask:
|
||||
self._tasks[task.id] = task
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> GenerationTask | None:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if t.project_id == project_id]
|
||||
|
||||
def list_by_user(self, user_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
|
||||
def update(self, task: GenerationTask) -> GenerationTask:
|
||||
self._tasks[task.id] = task
|
||||
return task
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._tasks.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||
items = [t for t in self._tasks.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]:
|
||||
return [t for t in self._tasks.values() if getattr(t, "source_edit_plan_id", "") == plan_id]
|
||||
|
||||
|
||||
class StubIngestJobRepository:
|
||||
def __init__(self, jobs: dict[str, IngestJob] | None = None):
|
||||
self._jobs = jobs or {}
|
||||
|
||||
def create(self, job: IngestJob) -> IngestJob:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> IngestJob | None:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def update(self, job: IngestJob) -> IngestJob:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def update_status(self, job_id: str, status, **kwargs):
|
||||
job = self._jobs.get(job_id)
|
||||
if job:
|
||||
job.status = status
|
||||
|
||||
def list_by_project(self, project_id: str, skip: int = 0, limit: int = 50) -> list[IngestJob]:
|
||||
return [j for j in self._jobs.values() if j.project_id == project_id][skip : skip + limit]
|
||||
|
||||
def list_by_library(self, library_id: str, skip: int = 0, limit: int = 50) -> list[IngestJob]:
|
||||
return [j for j in self._jobs.values() if j.library_id == library_id][skip : skip + limit]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Helpers & Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project:
|
||||
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
|
||||
|
||||
|
||||
def _make_generation_task(
|
||||
task_id: str = "gen-task-1",
|
||||
project_id: str = "proj-1",
|
||||
user_id: str = "user-test-001",
|
||||
status: GenerationTaskStatus = GenerationTaskStatus.PENDING,
|
||||
) -> GenerationTask:
|
||||
task = GenerationTask(
|
||||
id=task_id,
|
||||
project_id=project_id,
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="s1",
|
||||
voice_library_id="v1",
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
task.status = status
|
||||
return task
|
||||
|
||||
|
||||
def _make_ingest_job(
|
||||
job_id: str = "ingest-job-1",
|
||||
project_id: str = "proj-1",
|
||||
library_id: str = "lib-1",
|
||||
status: IngestJobStatus = IngestJobStatus.PENDING,
|
||||
) -> IngestJob:
|
||||
job = IngestJob(
|
||||
id=job_id,
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key="uploads/test.mp4",
|
||||
)
|
||||
job.status = status
|
||||
return job
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
|
||||
project = _make_project()
|
||||
project_repo = StubProjectRepository({project.id: project})
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
def _override_current_user():
|
||||
mock_auth = MagicMock(spec=AuthenticatedUser)
|
||||
mock_auth.user = _make_user()
|
||||
return mock_auth
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. GET /tasks — 列出用户任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListUserTasks:
|
||||
"""列出用户任务端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无任务时返回空列表。"""
|
||||
resp = client.get("/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
def test_list_returns_generation_tasks(self, client):
|
||||
"""返回当前用户的 generation 任务。"""
|
||||
# 直接在 repository 中注入任务
|
||||
from app.dependencies import get_generation_task_repository
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task1 = _make_generation_task("gen-1", status=GenerationTaskStatus.PENDING)
|
||||
task2 = _make_generation_task("gen-2", status=GenerationTaskStatus.COMPLETED)
|
||||
task_repo.create(task1)
|
||||
task_repo.create(task2)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _override_current_user():
|
||||
mock_auth = MagicMock(spec=AuthenticatedUser)
|
||||
mock_auth.user = _make_user()
|
||||
return mock_auth
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: StubIngestJobRepository()
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.get("/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
# 验证响应字段
|
||||
for item in data["items"]:
|
||||
assert "id" in item
|
||||
assert "task_type" in item
|
||||
assert item["task_type"] == "generation"
|
||||
assert "status" in item
|
||||
assert "current_step" in item
|
||||
assert "retryable" in item
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_tasks_sorted_by_updated_time(self, client):
|
||||
"""任务按更新时间倒序排列。"""
|
||||
# 由于两个任务同时创建,验证它们都出现在列表中
|
||||
resp = client.get("/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data["items"], list)
|
||||
|
||||
def test_task_response_fields(self, client):
|
||||
"""任务响应包含所有必需字段。"""
|
||||
resp = client.get("/tasks")
|
||||
assert resp.status_code == 200
|
||||
# 空列表也应该返回正确的结构
|
||||
assert resp.json()["items"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. POST /tasks/{task_id}/retry — 重试用户任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryUserTask:
|
||||
"""重试用户任务端点测试。"""
|
||||
|
||||
def test_retry_nonexistent_task_returns_404(self, client):
|
||||
"""重试不存在的任务返回 404。"""
|
||||
resp = client.post("/tasks/nonexistent-task-id/retry")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_retry_pending_task_returns_409(self, mock_celery, client):
|
||||
"""重试 pending 状态的任务返回 409(只有 failed 任务才能重试)。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
# 在 repository 中创建一个 pending 任务
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task = _make_generation_task("gen-pending", status=GenerationTaskStatus.PENDING)
|
||||
task_repo.create(task)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: StubIngestJobRepository()
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/gen-pending/retry")
|
||||
assert resp.status_code == 409
|
||||
assert "Only failed" in resp.json()["detail"]
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_retry_completed_task_returns_409(self, mock_celery, client):
|
||||
"""重试 completed 状态的任务返回 409。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task = _make_generation_task("gen-completed", status=GenerationTaskStatus.COMPLETED)
|
||||
task_repo.create(task)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: StubIngestJobRepository()
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/gen-completed/retry")
|
||||
assert resp.status_code == 409
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. GET /projects/{project_id}/tasks — 列出项目任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListProjectTasks:
|
||||
"""列出项目任务端点测试。"""
|
||||
|
||||
def test_empty_project_tasks(self, client):
|
||||
"""项目无任务时返回空列表。"""
|
||||
resp = client.get("/projects/proj-1/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
|
||||
def test_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
resp = client.get("/projects/nonexistent-project/tasks")
|
||||
assert resp.status_code == 404
|
||||
assert "Project not found" in resp.json()["detail"]
|
||||
|
||||
def test_returns_ingest_and_generation_tasks(self, client):
|
||||
"""返回项目中 ingest 和 generation 两种任务。"""
|
||||
# 在 repository 中注入任务
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
gen_task = _make_generation_task("gen-proj-1", status=GenerationTaskStatus.PENDING)
|
||||
task_repo.create(gen_task)
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
ingest_job = _make_ingest_job("ingest-proj-1", status=IngestJobStatus.PENDING)
|
||||
ingest_repo.create(ingest_job)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.get("/projects/proj-1/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
task_types = {item["task_type"] for item in data["items"]}
|
||||
assert "generation" in task_types
|
||||
assert "ingest" in task_types
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_project_task_response_fields(self, client):
|
||||
"""项目任务响应包含所有必需字段。"""
|
||||
resp = client.get("/projects/proj-1/tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data["items"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. POST /tasks/{task_type}/{source_id}/retry — 重试项目任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryProjectTask:
|
||||
"""重试项目任务端点测试。"""
|
||||
|
||||
def test_retry_unsupported_task_type_returns_400(self, client):
|
||||
"""不支持的任务类型返回 400。"""
|
||||
resp = client.post("/tasks/unknown/some-source-id/retry")
|
||||
assert resp.status_code == 400
|
||||
assert "Unsupported" in resp.json()["detail"]
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_retry_failed_generation_task(self, mock_celery, client):
|
||||
"""重试失败的 generation 任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task = _make_generation_task("gen-failed-1", status=GenerationTaskStatus.FAILED)
|
||||
task_repo.create(task)
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/generation/gen-failed-1/retry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["task_type"] == "generation"
|
||||
assert data["status"] == "pending"
|
||||
assert "current_step" in data
|
||||
# 验证新任务的 ID 不同于原任务
|
||||
assert data["source_id"] != "gen-failed-1"
|
||||
# 验证 Celery 任务被发送
|
||||
assert mock_celery.send_task.called
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_retry_failed_ingest_task(self, mock_celery, client):
|
||||
"""重试失败的 ingest 任务成功。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
job = _make_ingest_job("ingest-failed-1", status=IngestJobStatus.FAILED)
|
||||
ingest_repo.create(job)
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/ingest/ingest-failed-1/retry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["task_type"] == "ingest"
|
||||
assert data["status"] == "pending"
|
||||
assert mock_celery.send_task.called
|
||||
assert mock_celery.send_task.call_args[0][0] == "worker.ingest_asset"
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_retry_pending_generation_task_returns_409(self, client):
|
||||
"""重试 pending 状态的 generation 任务返回 409。"""
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
task = _make_generation_task("gen-pending-proj", status=GenerationTaskStatus.PENDING)
|
||||
task_repo.create(task)
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/generation/gen-pending-proj/retry")
|
||||
assert resp.status_code == 409
|
||||
assert "Only failed" in resp.json()["detail"]
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_retry_nonexistent_generation_task_returns_404(self, client):
|
||||
"""重试不存在的 generation 任务返回 404。"""
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/generation/nonexistent-id/retry")
|
||||
assert resp.status_code == 404
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
def test_retry_nonexistent_ingest_task_returns_404(self, client):
|
||||
"""重试不存在的 ingest 任务返回 404。"""
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
resp = tc.post("/tasks/ingest/nonexistent-id/retry")
|
||||
assert resp.status_code == 404
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 跨端点集成场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTaskCenterCrossEndpoint:
|
||||
"""任务中心跨端点集成测试。"""
|
||||
|
||||
@patch("app.api.routes.task_center.celery_app")
|
||||
def test_list_then_retry_then_list(self, mock_celery, client):
|
||||
"""列出任务 → 重试失败任务 → 再列出验证新任务。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
task_repo = StubGenerationTaskRepository()
|
||||
failed_task = _make_generation_task("gen-fail-cross", status=GenerationTaskStatus.FAILED)
|
||||
failed_task.error_message = "ffmpeg error"
|
||||
task_repo.create(failed_task)
|
||||
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
project_repo = StubProjectRepository({_make_project().id: _make_project()})
|
||||
|
||||
def _get_user():
|
||||
return MagicMock(spec=AuthenticatedUser, user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _get_user
|
||||
test_app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
test_app.dependency_overrides[get_generation_task_repository] = lambda: task_repo
|
||||
test_app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
|
||||
tc = TestClient(test_app)
|
||||
|
||||
# 1. 列出任务
|
||||
list_resp = tc.get("/tasks")
|
||||
assert list_resp.status_code == 200
|
||||
items = list_resp.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["retryable"] is True # failed 任务应可重试
|
||||
|
||||
# 2. 重试失败任务
|
||||
retry_resp = tc.post("/tasks/gen-fail-cross/retry")
|
||||
assert retry_resp.status_code == 200
|
||||
new_task_id = retry_resp.json()["source_id"]
|
||||
|
||||
# 3. 再次列出,应有2个任务(旧的failed + 新的pending)
|
||||
list_resp2 = tc.get("/tasks")
|
||||
assert list_resp2.status_code == 200
|
||||
items2 = list_resp2.json()["items"]
|
||||
assert len(items2) == 2
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,349 +0,0 @@
|
||||
"""
|
||||
模板分类 CRUD API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- GET /templates/categories/list — 列出分类
|
||||
- POST /templates/categories — 创建分类
|
||||
- DELETE /templates/categories/{category_id} — 删除分类
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
mock template repository,验证分类 CRUD 行为。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from app.api.routes import templates as templates_module
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.template import TemplateCategory
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryTemplateRepository:
|
||||
"""内存中的模板 Repository,仅实现分类相关方法。"""
|
||||
|
||||
def __init__(self):
|
||||
self._categories: dict[str, TemplateCategory] = {}
|
||||
self._templates = {}
|
||||
self._segments = {}
|
||||
|
||||
# ── 分类相关 ──
|
||||
|
||||
def list_categories(self, user_id: str) -> list[TemplateCategory]:
|
||||
return [c for c in self._categories.values() if c.user_id == user_id]
|
||||
|
||||
def create_category(self, category: TemplateCategory) -> TemplateCategory:
|
||||
# 检查重复名称
|
||||
existing = [c for c in self._categories.values() if c.user_id == category.user_id and c.name == category.name]
|
||||
if existing:
|
||||
raise ValueError(f"分类名称已存在: {category.name}")
|
||||
self._categories[category.id] = category
|
||||
return category
|
||||
|
||||
def get_category(self, category_id: str, user_id: str) -> TemplateCategory | None:
|
||||
cat = self._categories.get(category_id)
|
||||
if cat and cat.user_id == user_id:
|
||||
return cat
|
||||
return None
|
||||
|
||||
def delete_category(self, category_id: str, user_id: str) -> bool:
|
||||
cat = self.get_category(category_id, user_id)
|
||||
if cat:
|
||||
del self._categories[category_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── 模板相关(路由可能调用,提供占位实现) ──
|
||||
|
||||
def list_by_user(self, user_id: str, *, skip: int = 0, limit: int = 50):
|
||||
return []
|
||||
|
||||
def get(self, template_id: str, user_id: str):
|
||||
return None
|
||||
|
||||
def create(self, template):
|
||||
return template
|
||||
|
||||
def update(self, template):
|
||||
return template
|
||||
|
||||
def delete(self, template_id: str, user_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return 0
|
||||
|
||||
def list_segments(self, template_id: str):
|
||||
return []
|
||||
|
||||
def create_segments(self, segments):
|
||||
return segments
|
||||
|
||||
def delete_segments_by_template(self, template_id: str) -> int:
|
||||
return 0
|
||||
|
||||
def validate_template(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_category(
|
||||
name: str,
|
||||
user_id: str = "user-test-001",
|
||||
) -> TemplateCategory:
|
||||
return TemplateCategory(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def template_repo():
|
||||
return InMemoryTemplateRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(template_repo):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(templates_module.router, prefix="/templates")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
def _override_template_repo():
|
||||
return template_repo
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
# 覆盖路由模块内的 _get_template_repository 依赖
|
||||
test_app.dependency_overrides[templates_module._get_template_repository] = _override_template_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. GET /categories/list — 列出分类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListCategories:
|
||||
"""列出分类端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无分类时返回空列表。"""
|
||||
resp = client.get("/templates/categories/list")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
|
||||
def test_returns_user_categories(self, client, template_repo):
|
||||
"""只返回当前用户的分类。"""
|
||||
c1 = _make_category("美食", "user-test-001")
|
||||
c2 = _make_category("旅行", "user-test-001")
|
||||
c3 = _make_category("科技", "other-user")
|
||||
template_repo.create_category(c1)
|
||||
template_repo.create_category(c2)
|
||||
template_repo.create_category(c3)
|
||||
|
||||
resp = client.get("/templates/categories/list")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
names = {item["name"] for item in data["items"]}
|
||||
assert names == {"美食", "旅行"}
|
||||
|
||||
def test_response_fields(self, client, template_repo):
|
||||
"""响应包含所有必需字段。"""
|
||||
c = _make_category("测试分类")
|
||||
template_repo.create_category(c)
|
||||
|
||||
resp = client.get("/templates/categories/list")
|
||||
item = resp.json()["items"][0]
|
||||
assert "id" in item
|
||||
assert "user_id" in item
|
||||
assert "name" in item
|
||||
assert "created_at" in item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST /categories — 创建分类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateCategory:
|
||||
"""创建分类端点测试。"""
|
||||
|
||||
def test_create_valid_category(self, client):
|
||||
"""使用有效名称创建分类应成功。"""
|
||||
resp = client.post("/templates/categories", json={"name": "vlog"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "vlog"
|
||||
assert "id" in data
|
||||
assert data["user_id"] == "user-test-001"
|
||||
assert "created_at" in data
|
||||
|
||||
def test_create_with_chinese_name(self, client):
|
||||
"""支持中文分类名称。"""
|
||||
resp = client.post("/templates/categories", json={"name": "美食探店"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["name"] == "美食探店"
|
||||
|
||||
def test_create_persists_to_repo(self, client, template_repo):
|
||||
"""创建后分类保存到 repository。"""
|
||||
resp = client.post("/templates/categories", json={"name": "新知识"})
|
||||
cat_id = resp.json()["id"]
|
||||
|
||||
saved = template_repo.get_category(cat_id, "user-test-001")
|
||||
assert saved is not None
|
||||
assert saved.name == "新知识"
|
||||
|
||||
def test_create_missing_name_returns_422(self, client):
|
||||
"""缺少 name 字段返回 422。"""
|
||||
resp = client.post("/templates/categories", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_empty_name_returns_422(self, client):
|
||||
"""空名称返回 422(Pydantic min_length 校验)。"""
|
||||
resp = client.post("/templates/categories", json={"name": ""})
|
||||
# CreateCategoryRequest 没有 min_length 限制,此处验证实际行为
|
||||
assert resp.status_code in (201, 422)
|
||||
|
||||
def test_create_multiple_categories(self, client, template_repo):
|
||||
"""可创建多个不同名称的分类。"""
|
||||
names = ["美食", "旅行", "科技", "教育", "娱乐"]
|
||||
for name in names:
|
||||
resp = client.post("/templates/categories", json={"name": name})
|
||||
assert resp.status_code == 201
|
||||
|
||||
all_cats = template_repo.list_categories("user-test-001")
|
||||
assert len(all_cats) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. DELETE /categories/{category_id} — 删除分类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteCategory:
|
||||
"""删除分类端点测试。"""
|
||||
|
||||
def test_delete_existing_category(self, client, template_repo):
|
||||
"""删除存在的分类返回 204。"""
|
||||
c = _make_category("待删除")
|
||||
template_repo.create_category(c)
|
||||
|
||||
resp = client.delete(f"/templates/categories/{c.id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
assert template_repo.get_category(c.id, "user-test-001") is None
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, client):
|
||||
"""删除不存在的分类返回 404。"""
|
||||
resp = client.delete("/templates/categories/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower() or "Category" in resp.json()["detail"]
|
||||
|
||||
def test_delete_other_user_category_returns_404(self, client, template_repo):
|
||||
"""删除其他用户的分类返回 404(安全隔离)。"""
|
||||
c = _make_category("他人分类", user_id="other-user")
|
||||
template_repo.create_category(c)
|
||||
|
||||
resp = client.delete(f"/templates/categories/{c.id}")
|
||||
assert resp.status_code == 404
|
||||
# 验证未被删除
|
||||
assert template_repo.get_category(c.id, "other-user") is not None
|
||||
|
||||
def test_delete_idempotent(self, client, template_repo):
|
||||
"""删除后再次删除返回 404。"""
|
||||
c = _make_category("幂等测试")
|
||||
template_repo.create_category(c)
|
||||
|
||||
resp1 = client.delete(f"/templates/categories/{c.id}")
|
||||
assert resp1.status_code == 204
|
||||
|
||||
resp2 = client.delete(f"/templates/categories/{c.id}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCategoryCrudFlow:
|
||||
"""分类 CRUD 完整流程。"""
|
||||
|
||||
def test_create_list_delete_flow(self, client, template_repo):
|
||||
"""创建 → 列表 → 删除 完整流程。"""
|
||||
# 1. 创建
|
||||
create_resp = client.post("/templates/categories", json={"name": "流程测试"})
|
||||
assert create_resp.status_code == 201
|
||||
cat_id = create_resp.json()["id"]
|
||||
|
||||
# 2. 列表验证
|
||||
list_resp = client.get("/templates/categories/list")
|
||||
assert list_resp.status_code == 200
|
||||
assert len(list_resp.json()["items"]) == 1
|
||||
assert list_resp.json()["items"][0]["name"] == "流程测试"
|
||||
|
||||
# 3. 删除
|
||||
del_resp = client.delete(f"/templates/categories/{cat_id}")
|
||||
assert del_resp.status_code == 204
|
||||
|
||||
# 4. 再次列表验证已删除
|
||||
list_resp2 = client.get("/templates/categories/list")
|
||||
assert list_resp2.json()["items"] == []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,918 +0,0 @@
|
||||
"""
|
||||
TTS 合成 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- POST /tts/synthesize — 创建 TTS 合成任务
|
||||
- GET /tts/jobs — 列出 TTS 任务
|
||||
- GET /tts/jobs/{job_id} — 获取 TTS 任务详情
|
||||
- GET /tts/jobs/{job_id}/status — 获取 TTS 任务状态
|
||||
- DELETE /tts/jobs/{job_id} — 删除 TTS 任务
|
||||
- POST /tts/jobs/{job_id}/save-to-library — 保存到音色库
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
mock repository 和 CosyVoice 服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from app.api.routes.tts import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import (
|
||||
get_cosyvoice_service,
|
||||
get_user_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
get_voice_library_repository,
|
||||
)
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryTTSJobRepository:
|
||||
"""内存中的 TTS 任务 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: dict[str, TTSJob] = {}
|
||||
|
||||
def create(self, job: TTSJob) -> TTSJob:
|
||||
self._items[job.id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> TTSJob | None:
|
||||
return self._items.get(job_id)
|
||||
|
||||
def update(self, job: TTSJob) -> TTSJob:
|
||||
self._items[job.id] = job
|
||||
return job
|
||||
|
||||
def delete(self, job_id: str) -> bool:
|
||||
if job_id in self._items:
|
||||
del self._items[job_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status=None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[TTSJob]:
|
||||
items = [j for j in self._items.values() if j.user_id == user_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [j for j in items if j.status.value == status_str]
|
||||
items.sort(key=lambda j: j.created_at, reverse=True)
|
||||
return items[offset : offset + limit]
|
||||
|
||||
def count_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status=None,
|
||||
) -> int:
|
||||
items = [j for j in self._items.values() if j.user_id == user_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [j for j in items if j.status.value == status_str]
|
||||
return len(items)
|
||||
|
||||
def list_by_profile(
|
||||
self,
|
||||
voice_clone_profile_id: str,
|
||||
*,
|
||||
status=None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[TTSJob]:
|
||||
items = [j for j in self._items.values() if j.voice_clone_profile_id == voice_clone_profile_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [j for j in items if j.status.value == status_str]
|
||||
return items[offset : offset + limit]
|
||||
|
||||
|
||||
class InMemoryVoiceCloneProfileRepository:
|
||||
"""内存中的音色克隆档案 Repository(用于 TTS 测试)。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: dict[str, VoiceCloneProfile] = {}
|
||||
|
||||
def create(self, profile):
|
||||
self._items[profile.id] = profile
|
||||
return profile
|
||||
|
||||
def get(self, profile_id: str):
|
||||
return self._items.get(profile_id)
|
||||
|
||||
def update(self, profile):
|
||||
self._items[profile.id] = profile
|
||||
return profile
|
||||
|
||||
def delete(self, profile_id):
|
||||
if profile_id in self._items:
|
||||
del self._items[profile_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_by_user(self, user_id, **kwargs):
|
||||
return [p for p in self._items.values() if p.user_id == user_id]
|
||||
|
||||
def count_by_user(self, user_id, **kwargs):
|
||||
return len([p for p in self._items.values() if p.user_id == user_id])
|
||||
|
||||
def find_by_voice_id(self, voice_id):
|
||||
return None
|
||||
|
||||
def find_profile_ids_by_voice_ids(self, voice_ids):
|
||||
return {}
|
||||
|
||||
|
||||
class InMemoryVoiceLibraryRepository:
|
||||
"""内存中的配音库 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items = {}
|
||||
|
||||
def create(self, item):
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
def get(self, voice_id: str, user_id: str):
|
||||
item = self._items.get(voice_id)
|
||||
if item and item.user_id == user_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
def update(self, item):
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
def delete(self, voice_id: str, user_id: str) -> bool:
|
||||
item = self.get(voice_id, user_id)
|
||||
if item:
|
||||
del self._items[voice_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_by_user(self, user_id, **kwargs):
|
||||
return [i for i in self._items.values() if i.user_id == user_id]
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([i for i in self._items.values() if i.user_id == user_id])
|
||||
|
||||
|
||||
class InMemoryUserRepository:
|
||||
"""内存中的用户 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._users = {}
|
||||
|
||||
def save(self, user):
|
||||
self._users[user.id] = user
|
||||
|
||||
def find_by_id(self, user_id: str):
|
||||
return self._users.get(user_id)
|
||||
|
||||
def find_by_email(self, email: str):
|
||||
for u in self._users.values():
|
||||
if u.email == email:
|
||||
return u
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Mock CosyVoice 服务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockCosyVoiceService:
|
||||
"""Mock CosyVoice 服务。"""
|
||||
|
||||
def __init__(self, *, fail_submit: bool = False):
|
||||
self.fail_submit = fail_submit
|
||||
self.submit_called = False
|
||||
|
||||
def submit_synthesize_task(self, *, text: str, voice_id: str = "", **kwargs) -> dict:
|
||||
self.submit_called = True
|
||||
if self.fail_submit:
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
|
||||
raise CosyVoiceError("模拟 CosyVoice 合成失败")
|
||||
|
||||
return {
|
||||
"task_id": "mock-tts-task-123",
|
||||
"status": "processing",
|
||||
}
|
||||
|
||||
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
|
||||
return {
|
||||
"status": "completed",
|
||||
"audio_url": "https://cdn.example.com/tts/output.mp3",
|
||||
"duration": 5.5,
|
||||
"file_size": 88000,
|
||||
"sample_rate": 22050,
|
||||
"format": "mp3",
|
||||
}
|
||||
|
||||
def synthesize_speech(self, *, text: str, voice_id: str = "", **kwargs) -> dict:
|
||||
return {
|
||||
"audio_url": "https://cdn.example.com/tts/output.mp3",
|
||||
"duration": 5.5,
|
||||
"file_size": 88000,
|
||||
}
|
||||
|
||||
def submit_clone_task(self, **kwargs) -> dict:
|
||||
return {"task_id": "clone-1", "status": "processing"}
|
||||
|
||||
def list_preset_voices(self) -> list:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_tts_job(
|
||||
text: str = "你好,这是一段测试文本。",
|
||||
user_id: str = "user-test-001",
|
||||
status: TTSJobStatus = TTSJobStatus.PENDING,
|
||||
**kwargs,
|
||||
) -> TTSJob:
|
||||
job = TTSJob.create(
|
||||
user_id=user_id,
|
||||
input_text=text,
|
||||
voice_id=kwargs.get("voice_id", "voice-1"),
|
||||
voice_model=kwargs.get("voice_model", "cosyvoice-v2"),
|
||||
project_id=kwargs.get("project_id", ""),
|
||||
voice_clone_profile_id=kwargs.get("voice_clone_profile_id", ""),
|
||||
format=kwargs.get("format", "mp3"),
|
||||
sample_rate=kwargs.get("sample_rate", 22050),
|
||||
max_retries=kwargs.get("max_retries", 3),
|
||||
metadata=kwargs.get("metadata", None),
|
||||
)
|
||||
# 设置状态
|
||||
if status == TTSJobStatus.PROCESSING:
|
||||
job.mark_processing()
|
||||
elif status == TTSJobStatus.COMPLETED:
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url=kwargs.get("output_audio_url", "https://cdn.example.com/tts/out.mp3"),
|
||||
output_audio_key=kwargs.get("output_audio_key", "tts/out.mp3"),
|
||||
duration=kwargs.get("duration", 5.5),
|
||||
file_size=kwargs.get("file_size", 88000),
|
||||
)
|
||||
elif status == TTSJobStatus.FAILED:
|
||||
job.mark_processing()
|
||||
job.mark_failed("合成失败")
|
||||
elif status == TTSJobStatus.CANCELLED:
|
||||
job.mark_cancelled()
|
||||
return job
|
||||
|
||||
|
||||
def _make_voice_clone_profile(
|
||||
user_id: str = "user-test-001",
|
||||
status: VoiceCloneStatus = VoiceCloneStatus.READY,
|
||||
) -> VoiceCloneProfile:
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id=user_id,
|
||||
name="测试克隆音色",
|
||||
voice_model="cosyvoice-v2",
|
||||
)
|
||||
if status == VoiceCloneStatus.READY:
|
||||
profile.mark_processing()
|
||||
profile.mark_ready("clone-voice-001")
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tts_repo():
|
||||
return InMemoryTTSJobRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voice_clone_repo():
|
||||
return InMemoryVoiceCloneProfileRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voice_library_repo():
|
||||
return InMemoryVoiceLibraryRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_repo():
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(_make_user())
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cosyvoice_service():
|
||||
return MockCosyVoiceService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tts_repo, voice_clone_repo, voice_library_repo, user_repo, cosyvoice_service):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/tts")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
def _override_tts_repo():
|
||||
return tts_repo
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
|
||||
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: voice_clone_repo
|
||||
test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo
|
||||
test_app.dependency_overrides[get_user_repository] = lambda: user_repo
|
||||
|
||||
# 使用 FastAPI dependency_overrides 覆盖 TTS repository
|
||||
from app.api.routes import tts as tts_module
|
||||
|
||||
test_app.dependency_overrides[tts_module._get_repository] = lambda: tts_repo
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST /synthesize — 创建 TTS 合成任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateTTSJob:
|
||||
"""创建 TTS 合成任务端点测试。"""
|
||||
|
||||
def test_create_with_valid_text(self, client, cosyvoice_service):
|
||||
"""使用有效文本创建 TTS 任务。"""
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "你好,世界!",
|
||||
"voice_id": "voice-1",
|
||||
"voice_model": "cosyvoice-v2",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "job_id" in data
|
||||
assert data["message"] == "合成任务已创建"
|
||||
assert "status" in data
|
||||
|
||||
def test_create_persists_to_repository(self, client, tts_repo):
|
||||
"""创建后任务保存到 repository。"""
|
||||
resp = client.post("/tts/synthesize", json={"text": "持久化测试"})
|
||||
job_id = resp.json()["job_id"]
|
||||
|
||||
saved = tts_repo.get(job_id)
|
||||
assert saved is not None
|
||||
assert saved.input_text == "持久化测试"
|
||||
assert saved.user_id == "user-test-001"
|
||||
|
||||
def test_create_missing_text_returns_422(self, client):
|
||||
"""缺少 text 返回 422。"""
|
||||
resp = client.post("/tts/synthesize", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_empty_text_returns_422(self, client):
|
||||
"""空 text 返回 422。"""
|
||||
resp = client.post("/tts/synthesize", json={"text": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_with_custom_format(self, client):
|
||||
"""支持指定输出格式。"""
|
||||
for fmt in ["mp3", "wav", "pcm"]:
|
||||
resp = client.post("/tts/synthesize", json={"text": "测试", "format": fmt})
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_create_with_invalid_format_returns_422(self, client):
|
||||
"""无效格式在 Pydantic 层校验返回 422。"""
|
||||
# format 参数不在 TTSSynthesizeRequest schema 中,
|
||||
# 或者有默认值/枚举校验。此处测试额外字段会被忽略或校验失败。
|
||||
# 实际:schema 中 format 是可选的,有默认值,无效值会在领域层被捕获
|
||||
# 但 API 仍返回 201,任务标记为 failed(与音色克隆行为一致)
|
||||
resp = client.post("/tts/synthesize", json={"text": "测试", "format": "flac"})
|
||||
# 格式不在请求 schema 中时,FastAPI 会忽略额外字段,任务正常创建
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_create_with_metadata(self, client):
|
||||
"""支持自定义 metadata。"""
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "元数据测试",
|
||||
"metadata": {"source": "api", "version": "1.0"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_create_with_voice_clone_profile_id(self, client, voice_clone_repo):
|
||||
"""使用音色克隆档案创建 TTS。"""
|
||||
# 准备一个克隆档案
|
||||
profile = _make_voice_clone_profile()
|
||||
voice_clone_repo.create(profile)
|
||||
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "使用克隆音色",
|
||||
"voice_clone_profile_id": profile.id,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_create_with_nonexistent_clone_profile_returns_404(self, client):
|
||||
"""使用不存在的克隆档案返回 404。"""
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "测试",
|
||||
"voice_clone_profile_id": "nonexistent-profile",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_with_other_user_clone_profile_returns_403(self, client, voice_clone_repo):
|
||||
"""使用其他用户的克隆档案返回 403。"""
|
||||
profile = _make_voice_clone_profile(user_id="other-user")
|
||||
voice_clone_repo.create(profile)
|
||||
|
||||
resp = client.post(
|
||||
"/tts/synthesize",
|
||||
json={
|
||||
"text": "越权测试",
|
||||
"voice_clone_profile_id": profile.id,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. GET /jobs — 列出 TTS 任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListTTSJobs:
|
||||
"""列出 TTS 任务端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无任务时返回空列表。"""
|
||||
resp = client.get("/tts/jobs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
|
||||
def test_list_user_jobs(self, client, tts_repo):
|
||||
"""只返回当前用户的任务。"""
|
||||
j1 = _make_tts_job("任务1", "user-test-001")
|
||||
j2 = _make_tts_job("任务2", "user-test-001")
|
||||
j3 = _make_tts_job("他人任务", "other-user")
|
||||
tts_repo.create(j1)
|
||||
tts_repo.create(j2)
|
||||
tts_repo.create(j3)
|
||||
|
||||
resp = client.get("/tts/jobs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_filter_by_status(self, client, tts_repo):
|
||||
"""按状态筛选。"""
|
||||
completed = _make_tts_job("已完成", status=TTSJobStatus.COMPLETED)
|
||||
failed = _make_tts_job("已失败", status=TTSJobStatus.FAILED)
|
||||
tts_repo.create(completed)
|
||||
tts_repo.create(failed)
|
||||
|
||||
resp = client.get("/tts/jobs?status=completed")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["status"] == "completed"
|
||||
|
||||
def test_pagination(self, client, tts_repo):
|
||||
"""分页功能。"""
|
||||
for i in range(5):
|
||||
job = _make_tts_job(f"任务{i}")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get("/tts/jobs?page=1&page_size=2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 5
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
resp2 = client.get("/tts/jobs?page=2&page_size=2")
|
||||
assert resp2.json()["page"] == 2
|
||||
assert len(resp2.json()["items"]) == 2
|
||||
|
||||
resp3 = client.get("/tts/jobs?page=3&page_size=2")
|
||||
assert len(resp3.json()["items"]) == 1
|
||||
|
||||
def test_list_response_fields(self, client, tts_repo):
|
||||
"""列表响应包含所有必需字段。"""
|
||||
job = _make_tts_job("字段测试", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get("/tts/jobs")
|
||||
item = resp.json()["items"][0]
|
||||
for field in [
|
||||
"id",
|
||||
"user_id",
|
||||
"input_text",
|
||||
"voice_id",
|
||||
"voice_model",
|
||||
"status",
|
||||
"output_audio_url",
|
||||
"duration",
|
||||
"format",
|
||||
"error_message",
|
||||
"retry_count",
|
||||
"max_retries",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]:
|
||||
assert field in item, f"缺少字段: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. GET /jobs/{job_id} — 获取 TTS 任务详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTTSJob:
|
||||
"""获取 TTS 任务详情端点测试。"""
|
||||
|
||||
def test_get_existing_job(self, client, tts_repo):
|
||||
"""获取存在的任务返回详情。"""
|
||||
job = _make_tts_job("详情测试", voice_model="cosyvoice-v2")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == job.id
|
||||
assert data["input_text"] == "详情测试"
|
||||
assert data["voice_model"] == "cosyvoice-v2"
|
||||
|
||||
def test_get_nonexistent_returns_404(self, client):
|
||||
"""获取不存在的任务返回 404。"""
|
||||
resp = client.get("/tts/jobs/nonexistent-job-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
def test_get_other_user_job_returns_404(self, client, tts_repo):
|
||||
"""获取其他用户的任务返回 404(安全隔离)。"""
|
||||
job = _make_tts_job("他人任务", user_id="other-user")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_completed_job(self, client, tts_repo):
|
||||
"""获取已完成任务包含音频 URL 和时长。"""
|
||||
job = _make_tts_job("已完成", status=TTSJobStatus.COMPLETED, duration=10.5)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}")
|
||||
data = resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["output_audio_url"] != ""
|
||||
assert data["duration"] == 10.5
|
||||
assert data["file_size"] > 0
|
||||
|
||||
def test_get_failed_job(self, client, tts_repo):
|
||||
"""获取失败任务包含错误信息。"""
|
||||
job = _make_tts_job("失败任务", status=TTSJobStatus.FAILED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}")
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. GET /jobs/{job_id}/status — 获取 TTS 任务状态
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTTSJobStatus:
|
||||
"""获取 TTS 任务状态端点测试。"""
|
||||
|
||||
def test_status_pending(self, client, tts_repo):
|
||||
"""pending 状态。"""
|
||||
job = _make_tts_job("pending", status=TTSJobStatus.PENDING)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == job.id
|
||||
assert data["status"] == "pending"
|
||||
|
||||
def test_status_completed(self, client, tts_repo):
|
||||
"""completed 状态包含音频 URL。"""
|
||||
job = _make_tts_job("completed", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||||
data = resp.json()
|
||||
assert data["status"] == "completed"
|
||||
assert data["output_audio_url"] != ""
|
||||
assert data["duration"] > 0
|
||||
|
||||
def test_status_failed(self, client, tts_repo):
|
||||
"""failed 状态包含错误信息。"""
|
||||
job = _make_tts_job("failed", status=TTSJobStatus.FAILED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.get(f"/tts/jobs/{job.id}/status")
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] != ""
|
||||
|
||||
def test_status_nonexistent_returns_404(self, client):
|
||||
"""获取不存在任务的状态返回 404。"""
|
||||
resp = client.get("/tts/jobs/nonexistent/status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. DELETE /jobs/{job_id} — 删除 TTS 任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteTTSJob:
|
||||
"""删除 TTS 任务端点测试。"""
|
||||
|
||||
def test_delete_existing_job(self, client, tts_repo):
|
||||
"""删除存在的任务返回 204。"""
|
||||
job = _make_tts_job("待删除")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.delete(f"/tts/jobs/{job.id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
assert tts_repo.get(job.id) is None
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, client):
|
||||
"""删除不存在的任务返回 404。"""
|
||||
resp = client.delete("/tts/jobs/nonexistent-job-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_other_user_job_returns_404(self, client, tts_repo):
|
||||
"""删除其他用户的任务返回 404(安全隔离)。"""
|
||||
job = _make_tts_job("他人任务", user_id="other-user")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.delete(f"/tts/jobs/{job.id}")
|
||||
assert resp.status_code == 404
|
||||
# 验证未被删除
|
||||
assert tts_repo.get(job.id) is not None
|
||||
|
||||
def test_delete_idempotent(self, client, tts_repo):
|
||||
"""删除后再次删除返回 404。"""
|
||||
job = _make_tts_job("幂等测试")
|
||||
tts_repo.create(job)
|
||||
|
||||
resp1 = client.delete(f"/tts/jobs/{job.id}")
|
||||
assert resp1.status_code == 204
|
||||
|
||||
resp2 = client.delete(f"/tts/jobs/{job.id}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. POST /jobs/{job_id}/save-to-library — 保存到配音库
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSaveToLibrary:
|
||||
"""保存到配音库端点测试。"""
|
||||
|
||||
def test_save_completed_job(self, client, tts_repo):
|
||||
"""保存已完成的 TTS 任务到配音库。"""
|
||||
job = _make_tts_job("保存测试", status=TTSJobStatus.COMPLETED, duration=5.5)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(
|
||||
f"/tts/jobs/{job.id}/save-to-library",
|
||||
json={"name": "我的配音"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "我的配音"
|
||||
assert data["duration"] == 5.5
|
||||
assert data["status"] == "completed"
|
||||
assert "id" in data
|
||||
assert "audio_url" in data
|
||||
assert "voice_id" in data
|
||||
assert "voice_name" in data
|
||||
|
||||
def test_save_pending_job_returns_400(self, client, tts_repo):
|
||||
"""保存未完成的任务返回 400。"""
|
||||
job = _make_tts_job("未完成", status=TTSJobStatus.PENDING)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||||
assert resp.status_code == 400
|
||||
assert "not completed" in resp.json()["detail"].lower() or "完成" in resp.json()["detail"]
|
||||
|
||||
def test_save_failed_job_returns_400(self, client, tts_repo):
|
||||
"""保存失败的任务返回 400。"""
|
||||
job = _make_tts_job("失败", status=TTSJobStatus.FAILED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_save_nonexistent_job_returns_404(self, client):
|
||||
"""保存不存在的任务返回 404。"""
|
||||
resp = client.post("/tts/jobs/nonexistent/save-to-library")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_save_other_user_job_returns_404(self, client, tts_repo):
|
||||
"""保存其他用户的任务返回 404。"""
|
||||
job = _make_tts_job("他人任务", user_id="other-user", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_save_auto_generates_name(self, client, tts_repo):
|
||||
"""不指定名称时自动生成。"""
|
||||
job = _make_tts_job("自动命名", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library", json={})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] != ""
|
||||
# 自动生成的名称应该以 TTS- 开头
|
||||
assert data["name"].startswith("TTS-")
|
||||
|
||||
def test_save_creates_library_item(self, client, tts_repo, voice_library_repo):
|
||||
"""保存后配音库中新增一条记录。"""
|
||||
before_count = voice_library_repo.count_by_user("user-test-001")
|
||||
|
||||
job = _make_tts_job("入库测试", status=TTSJobStatus.COMPLETED)
|
||||
tts_repo.create(job)
|
||||
|
||||
resp = client.post(f"/tts/jobs/{job.id}/save-to-library", json={"name": "入库"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
after_count = voice_library_repo.count_by_user("user-test-001")
|
||||
assert after_count == before_count + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTTSLifecycle:
|
||||
"""TTS 完整生命周期测试。"""
|
||||
|
||||
def test_create_list_get_delete_flow(self, client, tts_repo):
|
||||
"""创建 → 列表 → 详情 → 删除 完整流程。"""
|
||||
# 1. 创建
|
||||
create_resp = client.post("/tts/synthesize", json={"text": "完整流程测试"})
|
||||
assert create_resp.status_code == 201
|
||||
job_id = create_resp.json()["job_id"]
|
||||
|
||||
# 2. 列表
|
||||
list_resp = client.get("/tts/jobs")
|
||||
assert list_resp.json()["total"] == 1
|
||||
|
||||
# 3. 详情
|
||||
detail_resp = client.get(f"/tts/jobs/{job_id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["input_text"] == "完整流程测试"
|
||||
|
||||
# 4. 状态
|
||||
status_resp = client.get(f"/tts/jobs/{job_id}/status")
|
||||
assert status_resp.status_code == 200
|
||||
|
||||
# 5. 删除
|
||||
del_resp = client.delete(f"/tts/jobs/{job_id}")
|
||||
assert del_resp.status_code == 204
|
||||
|
||||
# 6. 删除后列表为空
|
||||
list_resp2 = client.get("/tts/jobs")
|
||||
assert list_resp2.json()["total"] == 0
|
||||
|
||||
def test_create_simulate_complete_save_to_library(self, client, tts_repo):
|
||||
"""创建 → 模拟完成 → 保存到配音库 流程。"""
|
||||
# 创建任务
|
||||
create_resp = client.post("/tts/synthesize", json={"text": "入库流程"})
|
||||
job_id = create_resp.json()["job_id"]
|
||||
|
||||
# 模拟 worker 完成
|
||||
job = tts_repo.get(job_id)
|
||||
assert job is not None
|
||||
# 根据当前状态决定下一步:failed 先重置,pending 则转 processing,已是 processing 则跳过
|
||||
if job.status == TTSJobStatus.FAILED:
|
||||
job.prepare_retry()
|
||||
job.mark_processing()
|
||||
elif job.status == TTSJobStatus.PENDING:
|
||||
job.mark_processing()
|
||||
job.mark_completed(
|
||||
output_audio_url="https://cdn.example.com/tts/final.mp3",
|
||||
duration=8.0,
|
||||
file_size=128000,
|
||||
)
|
||||
tts_repo.update(job)
|
||||
|
||||
# 确认完成
|
||||
status_resp = client.get(f"/tts/jobs/{job_id}/status")
|
||||
assert status_resp.json()["status"] == "completed"
|
||||
|
||||
# 保存到配音库
|
||||
save_resp = client.post(
|
||||
f"/tts/jobs/{job_id}/save-to-library",
|
||||
json={"name": "最终配音"},
|
||||
)
|
||||
assert save_resp.status_code == 201
|
||||
assert save_resp.json()["name"] == "最终配音"
|
||||
assert save_resp.json()["duration"] == 8.0
|
||||
|
||||
def test_multiple_jobs_status_filter(self, client, tts_repo):
|
||||
"""多个任务时按状态筛选正确。"""
|
||||
# 创建不同状态的任务
|
||||
for text, status in [
|
||||
("任务A-完成", TTSJobStatus.COMPLETED),
|
||||
("任务B-完成", TTSJobStatus.COMPLETED),
|
||||
("任务C-失败", TTSJobStatus.FAILED),
|
||||
("任务D-处理中", TTSJobStatus.PROCESSING),
|
||||
]:
|
||||
job = _make_tts_job(text, status=status)
|
||||
tts_repo.create(job)
|
||||
|
||||
# 按状态筛选
|
||||
completed_resp = client.get("/tts/jobs?status=completed")
|
||||
assert completed_resp.json()["total"] == 2
|
||||
|
||||
failed_resp = client.get("/tts/jobs?status=failed")
|
||||
assert failed_resp.json()["total"] == 1
|
||||
|
||||
processing_resp = client.get("/tts/jobs?status=processing")
|
||||
assert processing_resp.json()["total"] == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,716 +0,0 @@
|
||||
"""
|
||||
声音克隆 API 集成测试。
|
||||
|
||||
覆盖端点:
|
||||
- POST /voice-clones — 创建声音克隆
|
||||
- GET /voice-clones — 列出声音克隆
|
||||
- GET /voice-clones/{clone_id} — 获取克隆详情
|
||||
- GET /voice-clones/{clone_id}/status — 获取克隆状态
|
||||
- POST /voice-clones/{clone_id}/retry — 重试克隆
|
||||
- DELETE /voice-clones/{clone_id} — 删除克隆
|
||||
|
||||
使用 FastAPI TestClient + dependency_overrides 模式,
|
||||
mock repository 和 CosyVoice 服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
|
||||
|
||||
from app.api.routes.voice_clones import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.voice_clone_profile import (
|
||||
VoiceCloneProfile,
|
||||
VoiceCloneStatus,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 内存 Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InMemoryVoiceCloneProfileRepository:
|
||||
"""内存中的音色克隆档案 Repository。"""
|
||||
|
||||
def __init__(self):
|
||||
self._items: dict[str, VoiceCloneProfile] = {}
|
||||
|
||||
def create(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
|
||||
self._items[profile.id] = profile
|
||||
return profile
|
||||
|
||||
def get(self, profile_id: str) -> VoiceCloneProfile | None:
|
||||
return self._items.get(profile_id)
|
||||
|
||||
def update(self, profile: VoiceCloneProfile) -> VoiceCloneProfile:
|
||||
self._items[profile.id] = profile
|
||||
return profile
|
||||
|
||||
def delete(self, profile_id: str) -> bool:
|
||||
if profile_id in self._items:
|
||||
del self._items[profile_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status=None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[VoiceCloneProfile]:
|
||||
items = [p for p in self._items.values() if p.user_id == user_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [p for p in items if p.status.value == status_str]
|
||||
# 按 created_at 倒序
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[offset : offset + limit]
|
||||
|
||||
def count_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status=None,
|
||||
) -> int:
|
||||
items = [p for p in self._items.values() if p.user_id == user_id]
|
||||
if status:
|
||||
status_str = status.value if hasattr(status, "value") else str(status)
|
||||
items = [p for p in items if p.status.value == status_str]
|
||||
return len(items)
|
||||
|
||||
def find_by_voice_id(self, voice_id: str) -> VoiceCloneProfile | None:
|
||||
for p in self._items.values():
|
||||
if p.voice_id == voice_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
def find_profile_ids_by_voice_ids(self, voice_ids: list[str]) -> dict[str, str]:
|
||||
result = {}
|
||||
for p in self._items.values():
|
||||
if p.voice_id in voice_ids:
|
||||
result[p.voice_id] = p.id
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Mock CosyVoice 服务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockCosyVoiceService:
|
||||
"""Mock CosyVoice 服务,模拟克隆任务提交和状态查询。"""
|
||||
|
||||
def __init__(self, *, fail_submit: bool = False, async_mode: bool = True):
|
||||
self.fail_submit = fail_submit
|
||||
self.async_mode = async_mode
|
||||
self.submit_called = False
|
||||
self.submit_args = None
|
||||
|
||||
def submit_clone_task(self, *, audio_url: str, voice_name: str, language: str = "zh-CN") -> dict:
|
||||
self.submit_called = True
|
||||
self.submit_args = {"audio_url": audio_url, "voice_name": voice_name, "language": language}
|
||||
|
||||
if self.fail_submit:
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
|
||||
raise CosyVoiceError("模拟 CosyVoice 提交失败")
|
||||
|
||||
if self.async_mode:
|
||||
# 异步模式:返回 task_id,需要轮询
|
||||
return {"task_id": "mock-task-123", "request_id": "req-456", "status": "processing"}
|
||||
else:
|
||||
# 同步模式:直接返回 voice_id
|
||||
return {"voice_id": "mock-voice-789", "status": "success"}
|
||||
|
||||
def check_task_status(self, task_id: str) -> dict:
|
||||
return {"status": "completed", "voice_id": "mock-voice-789"}
|
||||
|
||||
def list_preset_voices(self) -> list:
|
||||
return []
|
||||
|
||||
def submit_synthesize_task(self, **kwargs) -> dict:
|
||||
return {"task_id": "synth-1", "status": "processing"}
|
||||
|
||||
def synthesize_speech(self, **kwargs) -> dict:
|
||||
return {"audio_url": "https://example.com/audio.mp3", "duration": 5.0}
|
||||
|
||||
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
|
||||
return {
|
||||
"status": "completed",
|
||||
"audio_url": "https://example.com/audio.mp3",
|
||||
"duration": 5.0,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
defaults = dict(
|
||||
id="user-test-001",
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
username="testuser",
|
||||
subscription_plan="free",
|
||||
subscription_status="active",
|
||||
max_projects=3,
|
||||
max_storage_gb=10,
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
def _make_clone_profile(
|
||||
name: str = "我的音色",
|
||||
user_id: str = "user-test-001",
|
||||
status: VoiceCloneStatus = VoiceCloneStatus.PENDING,
|
||||
source_audio_url: str = "https://example.com/source.wav",
|
||||
**kwargs,
|
||||
) -> VoiceCloneProfile:
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
source_audio_url=source_audio_url,
|
||||
voice_model=kwargs.get("voice_model", "cosyvoice-v2"),
|
||||
language=kwargs.get("language", "zh-CN"),
|
||||
gender=kwargs.get("gender", "female"),
|
||||
max_retries=kwargs.get("max_retries", 3),
|
||||
metadata=kwargs.get("metadata", None),
|
||||
description=kwargs.get("description", ""),
|
||||
)
|
||||
# 设置状态
|
||||
if status == VoiceCloneStatus.PROCESSING:
|
||||
profile.mark_processing()
|
||||
profile.metadata = {"cosyvoice_task_id": "task-123"}
|
||||
elif status == VoiceCloneStatus.READY:
|
||||
profile.mark_processing()
|
||||
profile.mark_ready("voice-ready-001")
|
||||
elif status == VoiceCloneStatus.FAILED:
|
||||
profile.mark_processing()
|
||||
profile.mark_failed("模拟失败")
|
||||
elif status == VoiceCloneStatus.DISABLED:
|
||||
profile.mark_disabled()
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clone_repo():
|
||||
return InMemoryVoiceCloneProfileRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cosyvoice_service():
|
||||
return MockCosyVoiceService(async_mode=False) # 同步模式,简化测试
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(clone_repo, cosyvoice_service):
|
||||
"""创建带有依赖覆盖的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/voice-clones")
|
||||
|
||||
def _override_current_user():
|
||||
return AuthenticatedUser(user=_make_user())
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = _override_current_user
|
||||
test_app.dependency_overrides[get_voice_clone_profile_repository] = lambda: clone_repo
|
||||
test_app.dependency_overrides[get_cosyvoice_service] = lambda: cosyvoice_service
|
||||
|
||||
yield TestClient(test_app)
|
||||
|
||||
test_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. POST / — 创建声音克隆
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateVoiceClone:
|
||||
"""创建声音克隆端点测试。"""
|
||||
|
||||
def test_create_with_source_audio(self, client, cosyvoice_service):
|
||||
"""提供源音频时创建克隆,同步模式下直接 ready。"""
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "我的专属音色",
|
||||
"source_audio_url": "https://example.com/voice.wav",
|
||||
"voice_model": "cosyvoice-v2",
|
||||
"language": "zh-CN",
|
||||
"gender": "female",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "我的专属音色"
|
||||
assert data["source_audio_url"] == "https://example.com/voice.wav"
|
||||
assert data["voice_model"] == "cosyvoice-v2"
|
||||
assert data["language"] == "zh-CN"
|
||||
assert data["gender"] == "female"
|
||||
assert "id" in data
|
||||
assert len(data["id"]) > 0
|
||||
|
||||
# 同步模式下应直接 ready
|
||||
assert data["status"] == "ready"
|
||||
assert data["voice_id"] == "mock-voice-789"
|
||||
assert data["error_message"] == ""
|
||||
|
||||
def test_create_without_source_audio(self, client):
|
||||
"""不提供源音频时创建,状态为 pending。"""
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "待上传音色",
|
||||
"description": "等待上传音频",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "待上传音色"
|
||||
assert data["status"] == "pending"
|
||||
assert data["source_audio_url"] == ""
|
||||
assert data["voice_id"] == ""
|
||||
|
||||
def test_create_persists_to_repository(self, client, clone_repo):
|
||||
"""创建后档案保存到 repository。"""
|
||||
resp = client.post("/voice-clones", json={"name": "持久化测试"})
|
||||
profile_id = resp.json()["id"]
|
||||
|
||||
saved = clone_repo.get(profile_id)
|
||||
assert saved is not None
|
||||
assert saved.name == "持久化测试"
|
||||
assert saved.user_id == "user-test-001"
|
||||
|
||||
def test_create_missing_name_returns_422(self, client):
|
||||
"""缺少 name 返回 422。"""
|
||||
resp = client.post("/voice-clones", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_empty_name_returns_422(self, client):
|
||||
"""空 name 返回 422。"""
|
||||
resp = client.post("/voice-clones", json={"name": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_name_too_long_returns_422(self, client):
|
||||
"""名称超长返回 422。"""
|
||||
long_name = "a" * 101
|
||||
resp = client.post("/voice-clones", json={"name": long_name})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_with_metadata(self, client, cosyvoice_service):
|
||||
"""支持自定义 metadata。"""
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "带元数据的克隆",
|
||||
"source_audio_url": "https://example.com/v.wav",
|
||||
"metadata": {"source": "mobile_app", "version": "1.0"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["metadata"]["source"] == "mobile_app"
|
||||
assert data["metadata"]["version"] == "1.0"
|
||||
|
||||
def test_create_cosyvoice_failure_returns_failed(self, client, clone_repo, cosyvoice_service):
|
||||
"""CosyVoice 提交失败时返回 201 + failed 状态(不抛 500)。"""
|
||||
cosyvoice_service.fail_submit = True
|
||||
cosyvoice_service.async_mode = True # 异步模式才会调用 submit_clone_task
|
||||
|
||||
resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "会失败的克隆",
|
||||
"source_audio_url": "https://example.com/bad.wav",
|
||||
},
|
||||
)
|
||||
# 不抛 500,返回 201 + failed 状态
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. GET / — 列出声音克隆
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListVoiceClones:
|
||||
"""列出声音克隆端点测试。"""
|
||||
|
||||
def test_empty_list(self, client):
|
||||
"""无克隆时返回空列表。"""
|
||||
resp = client.get("/voice-clones")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_user_clones(self, client, clone_repo):
|
||||
"""只返回当前用户的克隆。"""
|
||||
p1 = _make_clone_profile("音色1", "user-test-001")
|
||||
p2 = _make_clone_profile("音色2", "user-test-001")
|
||||
p3 = _make_clone_profile("他人音色", "other-user")
|
||||
clone_repo.create(p1)
|
||||
clone_repo.create(p2)
|
||||
clone_repo.create(p3)
|
||||
|
||||
resp = client.get("/voice-clones")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 2
|
||||
assert len(data["items"]) == 2
|
||||
names = {item["name"] for item in data["items"]}
|
||||
assert names == {"音色1", "音色2"}
|
||||
|
||||
def test_filter_by_status(self, client, clone_repo):
|
||||
"""按状态筛选。"""
|
||||
ready = _make_clone_profile("已就绪", status=VoiceCloneStatus.READY)
|
||||
failed = _make_clone_profile("已失败", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(ready)
|
||||
clone_repo.create(failed)
|
||||
|
||||
resp = client.get("/voice-clones?status=ready")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["name"] == "已就绪"
|
||||
|
||||
def test_filter_by_failed_status(self, client, clone_repo):
|
||||
"""筛选失败状态。"""
|
||||
failed = _make_clone_profile("失败的", status=VoiceCloneStatus.FAILED)
|
||||
ready = _make_clone_profile("成功的", status=VoiceCloneStatus.READY)
|
||||
clone_repo.create(failed)
|
||||
clone_repo.create(ready)
|
||||
|
||||
resp = client.get("/voice-clones?status=failed")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
assert resp.json()["items"][0]["name"] == "失败的"
|
||||
|
||||
def test_list_response_fields(self, client, clone_repo):
|
||||
"""列表响应包含所有必需字段。"""
|
||||
p = _make_clone_profile("字段测试")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get("/voice-clones")
|
||||
item = resp.json()["items"][0]
|
||||
for field in [
|
||||
"id",
|
||||
"user_id",
|
||||
"name",
|
||||
"description",
|
||||
"source_audio_url",
|
||||
"voice_id",
|
||||
"voice_model",
|
||||
"language",
|
||||
"gender",
|
||||
"status",
|
||||
"error_message",
|
||||
"retry_count",
|
||||
"max_retries",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]:
|
||||
assert field in item, f"缺少字段: {field}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. GET /{clone_id} — 获取克隆详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetVoiceClone:
|
||||
"""获取克隆详情端点测试。"""
|
||||
|
||||
def test_get_existing_clone(self, client, clone_repo):
|
||||
"""获取存在的克隆返回详情。"""
|
||||
p = _make_clone_profile("详情测试", description="这是一段描述")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == p.id
|
||||
assert data["name"] == "详情测试"
|
||||
assert data["description"] == "这是一段描述"
|
||||
|
||||
def test_get_nonexistent_returns_404(self, client):
|
||||
"""获取不存在的克隆返回 404。"""
|
||||
resp = client.get("/voice-clones/nonexistent-clone-id")
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"].lower()
|
||||
|
||||
def test_get_other_user_clone_returns_404(self, client, clone_repo):
|
||||
"""获取其他用户的克隆返回 404(安全隔离)。"""
|
||||
p = _make_clone_profile("他人音色", user_id="other-user")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_ready_clone_has_voice_id(self, client, clone_repo):
|
||||
"""就绪状态的克隆有 voice_id。"""
|
||||
p = _make_clone_profile("就绪音色", status=VoiceCloneStatus.READY)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}")
|
||||
data = resp.json()
|
||||
assert data["status"] == "ready"
|
||||
assert data["voice_id"] == "voice-ready-001"
|
||||
|
||||
def test_get_failed_clone_has_error_message(self, client, clone_repo):
|
||||
"""失败状态的克隆有错误信息。"""
|
||||
p = _make_clone_profile("失败音色", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}")
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert "模拟失败" in data["error_message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. GET /{clone_id}/status — 获取克隆状态
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetVoiceCloneStatus:
|
||||
"""获取克隆状态端点测试。"""
|
||||
|
||||
def test_status_pending(self, client, clone_repo):
|
||||
"""pending 状态。"""
|
||||
p = _make_clone_profile("pending", status=VoiceCloneStatus.PENDING)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == p.id
|
||||
assert data["status"] == "pending"
|
||||
assert data["retry_count"] == 0
|
||||
|
||||
def test_status_ready(self, client, clone_repo):
|
||||
"""ready 状态包含 voice_id。"""
|
||||
p = _make_clone_profile("ready", status=VoiceCloneStatus.READY)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}/status")
|
||||
data = resp.json()
|
||||
assert data["status"] == "ready"
|
||||
assert data["voice_id"] == "voice-ready-001"
|
||||
|
||||
def test_status_failed(self, client, clone_repo):
|
||||
"""failed 状态包含错误信息。"""
|
||||
p = _make_clone_profile("failed", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.get(f"/voice-clones/{p.id}/status")
|
||||
data = resp.json()
|
||||
assert data["status"] == "failed"
|
||||
assert data["error_message"] != ""
|
||||
assert data["retry_count"] == 0 # mark_failed 不增加 retry_count,只有重试时才增加
|
||||
|
||||
def test_status_nonexistent_returns_404(self, client):
|
||||
"""获取不存在克隆的状态返回 404。"""
|
||||
resp = client.get("/voice-clones/nonexistent/status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. POST /{clone_id}/retry — 重试克隆
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryVoiceClone:
|
||||
"""重试克隆端点测试。"""
|
||||
|
||||
def test_retry_failed_clone(self, client, clone_repo, cosyvoice_service):
|
||||
"""重试失败的克隆应成功。"""
|
||||
cosyvoice_service.async_mode = False
|
||||
p = _make_clone_profile("重试测试", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 同步模式下重试后应变为 ready
|
||||
assert data["status"] == "ready"
|
||||
assert data["retry_count"] >= 1
|
||||
|
||||
def test_retry_nonexistent_returns_404(self, client):
|
||||
"""重试不存在的克隆返回 404。"""
|
||||
resp = client.post("/voice-clones/nonexistent/retry")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_retry_ready_clone_returns_400(self, client, clone_repo):
|
||||
"""重试已就绪的克隆返回 400(不可重试)。"""
|
||||
p = _make_clone_profile("已就绪", status=VoiceCloneStatus.READY)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert resp.status_code == 400
|
||||
assert "retryable" in resp.json()["detail"].lower() or "not" in resp.json()["detail"].lower()
|
||||
|
||||
def test_retry_processing_clone_returns_400(self, client, clone_repo):
|
||||
"""重试处理中的克隆返回 400。"""
|
||||
p = _make_clone_profile("处理中", status=VoiceCloneStatus.PROCESSING)
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_retry_increments_retry_count(self, client, clone_repo, cosyvoice_service):
|
||||
"""重试后重试次数增加。"""
|
||||
cosyvoice_service.async_mode = False
|
||||
p = _make_clone_profile("重试计数", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
before_count = p.retry_count
|
||||
resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
after_count = resp.json()["retry_count"]
|
||||
|
||||
assert after_count > before_count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. DELETE /{clone_id} — 删除克隆
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteVoiceClone:
|
||||
"""删除克隆端点测试。"""
|
||||
|
||||
def test_delete_existing_clone(self, client, clone_repo):
|
||||
"""删除存在的克隆返回 204。"""
|
||||
p = _make_clone_profile("待删除")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.delete(f"/voice-clones/{p.id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
assert clone_repo.get(p.id) is None
|
||||
|
||||
def test_delete_nonexistent_returns_404(self, client):
|
||||
"""删除不存在的克隆返回 404。"""
|
||||
resp = client.delete("/voice-clones/nonexistent-clone-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_other_user_clone_returns_404(self, client, clone_repo):
|
||||
"""删除其他用户的克隆返回 404(安全隔离)。"""
|
||||
p = _make_clone_profile("他人音色", user_id="other-user")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp = client.delete(f"/voice-clones/{p.id}")
|
||||
assert resp.status_code == 404
|
||||
# 验证未被删除
|
||||
assert clone_repo.get(p.id) is not None
|
||||
|
||||
def test_delete_idempotent(self, client, clone_repo):
|
||||
"""删除后再次删除返回 404。"""
|
||||
p = _make_clone_profile("幂等测试")
|
||||
clone_repo.create(p)
|
||||
|
||||
resp1 = client.delete(f"/voice-clones/{p.id}")
|
||||
assert resp1.status_code == 204
|
||||
|
||||
resp2 = client.delete(f"/voice-clones/{p.id}")
|
||||
assert resp2.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. 跨端点场景
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVoiceCloneLifecycle:
|
||||
"""音色克隆完整生命周期测试。"""
|
||||
|
||||
def test_full_lifecycle_create_list_get_delete(self, client, clone_repo, cosyvoice_service):
|
||||
"""创建 → 列表 → 详情 → 删除 完整流程。"""
|
||||
# 1. 创建
|
||||
create_resp = client.post(
|
||||
"/voice-clones",
|
||||
json={
|
||||
"name": "生命周期测试",
|
||||
"source_audio_url": "https://example.com/voice.wav",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
clone_id = create_resp.json()["id"]
|
||||
|
||||
# 2. 列表
|
||||
list_resp = client.get("/voice-clones")
|
||||
assert list_resp.json()["total"] == 1
|
||||
|
||||
# 3. 详情
|
||||
detail_resp = client.get(f"/voice-clones/{clone_id}")
|
||||
assert detail_resp.status_code == 200
|
||||
assert detail_resp.json()["name"] == "生命周期测试"
|
||||
|
||||
# 4. 状态
|
||||
status_resp = client.get(f"/voice-clones/{clone_id}/status")
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.json()["status"] == "ready"
|
||||
|
||||
# 5. 删除
|
||||
del_resp = client.delete(f"/voice-clones/{clone_id}")
|
||||
assert del_resp.status_code == 204
|
||||
|
||||
# 6. 删除后列表为空
|
||||
list_resp2 = client.get("/voice-clones")
|
||||
assert list_resp2.json()["total"] == 0
|
||||
|
||||
def test_failed_retry_flow(self, client, clone_repo, cosyvoice_service):
|
||||
"""失败 → 重试 → 成功 流程。"""
|
||||
# 创建一个失败的克隆
|
||||
p = _make_clone_profile("失败重试", status=VoiceCloneStatus.FAILED)
|
||||
clone_repo.create(p)
|
||||
|
||||
# 确认状态
|
||||
status_resp = client.get(f"/voice-clones/{p.id}/status")
|
||||
assert status_resp.json()["status"] == "failed"
|
||||
|
||||
# 重试
|
||||
cosyvoice_service.async_mode = False
|
||||
retry_resp = client.post(f"/voice-clones/{p.id}/retry")
|
||||
assert retry_resp.status_code == 200
|
||||
assert retry_resp.json()["status"] == "ready"
|
||||
|
||||
# 再次确认状态
|
||||
status_resp2 = client.get(f"/voice-clones/{p.id}/status")
|
||||
assert status_resp2.json()["status"] == "ready"
|
||||
assert status_resp2.json()["voice_id"] != ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1,254 +0,0 @@
|
||||
"""
|
||||
DELETE /asset-libraries/{library_id} 单元测试
|
||||
|
||||
覆盖:
|
||||
- 正常删除空素材库(204)
|
||||
- 删除含素材的库(同时删除库内素材)
|
||||
- 素材库不存在(404)
|
||||
- 无权限访问(403)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.adapters.in_memory.asset_library_repository import InMemoryAssetLibraryRepository
|
||||
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Project Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProject:
|
||||
"""最小化 Project stub,支持 can_access"""
|
||||
|
||||
def __init__(self, project_id: str, owner_id: str):
|
||||
self.id = project_id
|
||||
self._owner_id = owner_id
|
||||
|
||||
def can_access(self, user_id: str) -> bool:
|
||||
return user_id == self._owner_id
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects: dict[str, StubProject] | None = None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def find_by_id(self, project_id: str):
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_auth_user(user_id: str = "user-001"):
|
||||
from app.auth import AuthenticatedUser
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
user = User(id=user_id, email="test@example.com", display_name="测试用户")
|
||||
return AuthenticatedUser(user=user)
|
||||
|
||||
|
||||
def _create_test_app(
|
||||
library_repo: InMemoryAssetLibraryRepository,
|
||||
asset_repo: InMemoryAssetRepository,
|
||||
project_repo: StubProjectRepository,
|
||||
user_id: str = "user-001",
|
||||
):
|
||||
from app.api.routes import asset_libraries as module
|
||||
from app.api.routes.asset_libraries import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/asset-libraries")
|
||||
|
||||
app.dependency_overrides[module.get_current_user] = lambda: _make_auth_user(user_id)
|
||||
app.dependency_overrides[module.get_asset_library_repository] = lambda: library_repo
|
||||
app.dependency_overrides[module.get_asset_repository] = lambda: asset_repo
|
||||
app.dependency_overrides[module.get_project_repository] = lambda: project_repo
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteAssetLibrary:
|
||||
"""DELETE /asset-libraries/{library_id} 测试"""
|
||||
|
||||
def test_delete_empty_library(self):
|
||||
"""删除空素材库 → 204"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
proj_repo = StubProjectRepository({"proj-1": StubProject("proj-1", "user-001")})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
library = AssetLibrary(
|
||||
id="lib-1",
|
||||
project_id="proj-1",
|
||||
name="视频素材库",
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
lib_repo.create(library)
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 204
|
||||
|
||||
# 验证库已删除
|
||||
assert lib_repo.find_by_id("lib-1") is None
|
||||
|
||||
def test_delete_library_with_assets(self):
|
||||
"""删除含素材的库 → 库和素材都被删除"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
proj_repo = StubProjectRepository({"proj-1": StubProject("proj-1", "user-001")})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
library = AssetLibrary(
|
||||
id="lib-1",
|
||||
project_id="proj-1",
|
||||
name="视频素材库",
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
asset_count=3,
|
||||
total_size=1000,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
lib_repo.create(library)
|
||||
|
||||
# 创建 3 个素材
|
||||
for i in range(3):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=f"video_{i}.mp4",
|
||||
storage_key=f"uploads/video_{i}.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
|
||||
# 创建一个不属于该库的素材(不应被删除)
|
||||
other_asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-other",
|
||||
name="other.mp4",
|
||||
storage_key="uploads/other.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
asset_repo.create(other_asset)
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 204
|
||||
|
||||
# 库已删除
|
||||
assert lib_repo.find_by_id("lib-1") is None
|
||||
# 库内素材已删除
|
||||
assert asset_repo.find_by_library("lib-1") == []
|
||||
# 其他素材未受影响
|
||||
assert asset_repo.get(other_asset.id) is not None
|
||||
|
||||
def test_delete_nonexistent_library(self):
|
||||
"""删除不存在的素材库 → 404"""
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
proj_repo = StubProjectRepository({"proj-1": StubProject("proj-1", "user-001")})
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
assert "素材库不存在" in response.json()["detail"]
|
||||
|
||||
def test_delete_library_access_denied(self):
|
||||
"""无权限用户删除素材库 → 403"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
# 项目属于 user-002,当前用户是 user-001
|
||||
proj_repo = StubProjectRepository({"proj-1": StubProject("proj-1", "user-002")})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
library = AssetLibrary(
|
||||
id="lib-1",
|
||||
project_id="proj-1",
|
||||
name="视频素材库",
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
lib_repo.create(library)
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo, user_id="user-001")
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.json()["detail"]
|
||||
|
||||
# 库未被删除
|
||||
assert lib_repo.find_by_id("lib-1") is not None
|
||||
|
||||
def test_delete_library_project_not_found(self):
|
||||
"""素材库所属项目不存在 → 404"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lib_repo = InMemoryAssetLibraryRepository()
|
||||
asset_repo = InMemoryAssetRepository()
|
||||
# 空的项目仓库,找不到项目
|
||||
proj_repo = StubProjectRepository({})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
library = AssetLibrary(
|
||||
id="lib-1",
|
||||
project_id="proj-missing",
|
||||
name="视频素材库",
|
||||
kind=AssetLibraryKind.VIDEO,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
lib_repo.create(library)
|
||||
|
||||
app = _create_test_app(lib_repo, asset_repo, proj_repo)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.delete("/api/v1/asset-libraries/lib-1")
|
||||
assert response.status_code == 404
|
||||
@@ -404,7 +404,7 @@ class TestAIRecommendEndpoint:
|
||||
json={"asset_ids": ["asset-1"]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "当前计划状态" in resp.json()["detail"] or "编辑计划" in resp.json()["detail"]
|
||||
assert "draft/editing" in resp.json()["detail"]
|
||||
|
||||
def test_ai_recommend_with_custom_params(self, ai_client):
|
||||
c, repo = ai_client
|
||||
|
||||
@@ -332,22 +332,17 @@ class TestGeneratePlan:
|
||||
assert resp.status_code == 404
|
||||
assert "不存在" in resp.json()["detail"]
|
||||
|
||||
def test_generate_draft_auto_transition_to_editing(
|
||||
def test_generate_wrong_status_draft(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
) -> None:
|
||||
"""draft 状态自动转 editing(自动兜底),然后因 0 片段报错"""
|
||||
"""draft 状态 → 400"""
|
||||
plan = _make_plan(status=EditPlanStatus.DRAFT)
|
||||
plan_repo.create(plan)
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
# draft 自动转 editing,但没有片段所以还是 400
|
||||
assert resp.status_code == 400
|
||||
assert "请先添加片段后再生成视频" in resp.json()["detail"]
|
||||
# 验证状态已自动转为 editing
|
||||
updated_plan = plan_repo.get(plan.id)
|
||||
assert updated_plan is not None
|
||||
assert updated_plan.status == EditPlanStatus.EDITING
|
||||
assert "editing" in resp.json()["detail"]
|
||||
|
||||
def test_generate_wrong_status_rendering(
|
||||
self,
|
||||
@@ -592,79 +587,3 @@ class TestResponseSchema:
|
||||
data = resp.json()
|
||||
expected_keys = {"plan_id", "plan_status", "generation_task_id", "clips"}
|
||||
assert set(data.keys()) == expected_keys
|
||||
|
||||
|
||||
# ── P0-1: 生成接口错误处理 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGeneratePlanErrorHandling:
|
||||
"""P0-1: generate 端点异常时返回明确错误信息,不裸 500"""
|
||||
|
||||
def test_generate_internal_error_returns_clear_message(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""核心流程抛异常 → 500 + 用户友好的错误信息(不暴露技术细节)"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
# 模拟 Celery 调度失败
|
||||
mock_celery.send_task.side_effect = RuntimeError("Redis 连接超时")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 500
|
||||
data = resp.json()
|
||||
# 验证返回了用户友好的错误信息,不暴露技术细节
|
||||
assert "生成失败" in data["detail"]
|
||||
assert "RuntimeError" not in data["detail"]
|
||||
assert "Redis" not in data["detail"]
|
||||
|
||||
def test_generate_error_rolls_back_plan_status(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""异常时将计划标记为 failed(RENDERING → FAILED 是合法流转)"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
mock_celery.send_task.side_effect = RuntimeError("调度失败")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 500
|
||||
# 计划状态应变为 failed
|
||||
updated = plan_repo.get(plan.id)
|
||||
assert updated.status == EditPlanStatus.FAILED
|
||||
|
||||
def test_generate_error_detail_is_user_friendly(
|
||||
self,
|
||||
client: TestClient,
|
||||
plan_repo: StubEditPlanRepository,
|
||||
clip_repo: StubEditPlanClipRepository,
|
||||
) -> None:
|
||||
"""错误信息对用户友好,不暴露技术细节(异常类型、内部错误信息)"""
|
||||
plan = _make_plan(status=EditPlanStatus.EDITING)
|
||||
plan_repo.create(plan)
|
||||
clip = _make_clip(plan.id, order=1)
|
||||
clip_repo.create(clip)
|
||||
|
||||
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
|
||||
mock_celery.send_task.side_effect = ConnectionError("Broker 不可达")
|
||||
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
|
||||
|
||||
assert resp.status_code == 500
|
||||
detail = resp.json()["detail"]
|
||||
# 验证不暴露技术细节
|
||||
assert "ConnectionError" not in detail
|
||||
assert "Broker 不可达" not in detail
|
||||
# 验证返回了用户友好的提示
|
||||
assert "生成失败" in detail
|
||||
|
||||
@@ -509,7 +509,7 @@ class TestGenerationWorkflow:
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is False
|
||||
assert "编辑" in reason or "模板" in reason
|
||||
assert "editing" in reason
|
||||
|
||||
def test_can_generate_no_clips_fails(self):
|
||||
svc = _make_service()
|
||||
@@ -517,7 +517,7 @@ class TestGenerationWorkflow:
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is False
|
||||
assert "请先添加片段后再生成视频" in reason
|
||||
assert "没有片段" in reason
|
||||
|
||||
def test_mark_clips_ready(self):
|
||||
svc = _make_service()
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
"""P0-2: Celery 任务 render_edit_plan 失败时更新 GenerationTask 状态。
|
||||
|
||||
验证:
|
||||
- 异常发生时 GenerationTask 状态更新为 failed
|
||||
- error_message 记录了异常类型和描述
|
||||
- completed_at 被设置
|
||||
- 即使 generation_task_id 为空也不崩溃
|
||||
- 即使更新 GenerationTask 本身失败也不影响 retry
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from types import ModuleType
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
# ── Mock worker 模块以避免数据库连接 ──────────────────────────────────────────
|
||||
# worker_app.db 在 import 时会尝试连接数据库,必须在导入 task 模块前 mock
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
# 预注册 mock 模块,阻止真实数据库初始化
|
||||
_mock_db_mod = ModuleType("worker_app.db")
|
||||
_mock_db_mod.SessionLocal = MagicMock()
|
||||
sys.modules.setdefault("worker_app.db", _mock_db_mod)
|
||||
|
||||
_mock_celery_mod = ModuleType("worker_app.celery_app")
|
||||
_mock_celery_app = MagicMock()
|
||||
# 让 @celery_app.task(...) 装饰器透传原始函数,否则函数变成 MagicMock
|
||||
_mock_celery_app.task = lambda **kwargs: lambda fn: fn
|
||||
_mock_celery_mod.celery_app = _mock_celery_app
|
||||
sys.modules.setdefault("worker_app.celery_app", _mock_celery_mod)
|
||||
|
||||
|
||||
# ── Stub domain objects ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubStatus:
|
||||
value: str
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, str):
|
||||
return self.value == other
|
||||
if isinstance(other, _StubStatus):
|
||||
return self.value == other.value
|
||||
return NotImplemented
|
||||
|
||||
|
||||
@dataclass
|
||||
class StubEditPlan:
|
||||
id: str = "plan-001"
|
||||
template_id: str = "tmpl-001"
|
||||
status: Any = None
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
def mark_failed(self):
|
||||
self.status = _StubStatus("failed")
|
||||
|
||||
def mark_completed(self):
|
||||
self.status = _StubStatus("completed")
|
||||
|
||||
|
||||
@dataclass
|
||||
class StubGenerationTask:
|
||||
id: str = "gen-task-001"
|
||||
status: Any = field(default_factory=lambda: _StubStatus("pending"))
|
||||
error_message: str = ""
|
||||
progress: float = 0.0
|
||||
result_count: int = 0
|
||||
started_at: Any = None
|
||||
completed_at: Any = None
|
||||
project_id: str = ""
|
||||
created_by_user_id: str = "user-001"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StubClip:
|
||||
id: str = "clip-001"
|
||||
plan_id: str = "plan-001"
|
||||
asset_id: str = "assets/video.mp4"
|
||||
order: int = 1
|
||||
status: Any = field(default_factory=lambda: _StubStatus("ready"))
|
||||
transition_effect: str = ""
|
||||
text_content: str = ""
|
||||
clip_type: str = "MAIN"
|
||||
duration: float = 0.0
|
||||
|
||||
def mark_failed(self):
|
||||
self.status = _StubStatus("failed")
|
||||
|
||||
def mark_rendered(self):
|
||||
self.status = _StubStatus("rendered")
|
||||
|
||||
|
||||
# ── Stub repositories ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StubPlanRepo:
|
||||
def __init__(self, plan: StubEditPlan):
|
||||
self._plan = plan
|
||||
|
||||
def get(self, plan_id: str) -> Optional[StubEditPlan]:
|
||||
if plan_id == self._plan.id:
|
||||
return self._plan
|
||||
return None
|
||||
|
||||
def update(self, plan: StubEditPlan) -> StubEditPlan:
|
||||
self._plan = plan
|
||||
return plan
|
||||
|
||||
|
||||
class StubClipRepo:
|
||||
def __init__(self, clips: list[StubClip] | None = None):
|
||||
self._clips = clips or []
|
||||
|
||||
def list_by_plan(self, plan_id: str, skip: int = 0, limit: int = 10000) -> list[StubClip]:
|
||||
return [c for c in self._clips if c.plan_id == plan_id]
|
||||
|
||||
def get(self, clip_id: str) -> Optional[StubClip]:
|
||||
for c in self._clips:
|
||||
if c.id == clip_id:
|
||||
return c
|
||||
return None
|
||||
|
||||
def update(self, clip: StubClip) -> StubClip:
|
||||
return clip
|
||||
|
||||
|
||||
class StubGenTaskRepo:
|
||||
def __init__(self, task: StubGenerationTask | None = None):
|
||||
self._store: dict[str, StubGenerationTask] = {}
|
||||
if task:
|
||||
self._store[task.id] = task
|
||||
|
||||
def get(self, task_id: str) -> Optional[StubGenerationTask]:
|
||||
return self._store.get(task_id)
|
||||
|
||||
def update(self, task: StubGenerationTask) -> StubGenerationTask:
|
||||
self._store[task.id] = task
|
||||
return task
|
||||
|
||||
|
||||
# ── Import task module (after mocks are in place) ─────────────────────────────
|
||||
|
||||
from worker_app.tasks.edit_plan_generation import render_edit_plan
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRenderEditPlanFailureUpdatesGenTask:
|
||||
"""P0-2: render_edit_plan 异常时更新 GenerationTask 状态为 failed"""
|
||||
|
||||
def _make_bound_task(self):
|
||||
"""构建绑定的 Celery task mock"""
|
||||
task = MagicMock()
|
||||
task.retry = MagicMock(side_effect=RuntimeError("retry called"))
|
||||
return task
|
||||
|
||||
def test_exception_marks_gen_task_failed(self):
|
||||
"""异常时 GenerationTask.status 被设为 failed"""
|
||||
plan = StubEditPlan(status=_StubStatus("rendering"))
|
||||
plan.config["generation_task_id"] = "gen-task-001"
|
||||
gen_task = StubGenerationTask(id="gen-task-001", status=_StubStatus("running"))
|
||||
|
||||
plan_repo = StubPlanRepo(plan)
|
||||
clip_repo = StubClipRepo([])
|
||||
gen_task_repo = StubGenTaskRepo(gen_task)
|
||||
|
||||
# 让 clip_repo 抛异常以触发 except 路径
|
||||
clip_repo_bad = MagicMock()
|
||||
clip_repo_bad.list_by_plan.side_effect = RuntimeError("OSS 连接失败")
|
||||
|
||||
def fake_get_repos():
|
||||
yield plan_repo, clip_repo_bad, gen_task_repo, MagicMock()
|
||||
|
||||
bound_task = self._make_bound_task()
|
||||
|
||||
with patch(
|
||||
"worker_app.tasks.edit_plan_generation._get_repos",
|
||||
side_effect=fake_get_repos,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="retry called"):
|
||||
render_edit_plan(bound_task, "plan-001")
|
||||
|
||||
# 核心断言:GenerationTask 状态为 failed(生产代码赋值为字符串)
|
||||
assert gen_task.status == "failed"
|
||||
|
||||
def test_exception_records_error_message(self):
|
||||
"""异常时 error_message 包含异常类型和描述"""
|
||||
plan = StubEditPlan(status=_StubStatus("rendering"))
|
||||
plan.config["generation_task_id"] = "gen-task-001"
|
||||
gen_task = StubGenerationTask(id="gen-task-001", status=_StubStatus("running"))
|
||||
|
||||
plan_repo = StubPlanRepo(plan)
|
||||
clip_repo = MagicMock()
|
||||
clip_repo.list_by_plan.side_effect = RuntimeError("DB 查询超时")
|
||||
gen_task_repo = StubGenTaskRepo(gen_task)
|
||||
|
||||
def fake_get_repos():
|
||||
yield plan_repo, clip_repo, gen_task_repo, MagicMock()
|
||||
|
||||
bound_task = self._make_bound_task()
|
||||
|
||||
with patch(
|
||||
"worker_app.tasks.edit_plan_generation._get_repos",
|
||||
side_effect=fake_get_repos,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="retry called"):
|
||||
render_edit_plan(bound_task, "plan-001")
|
||||
|
||||
assert gen_task.status == "failed"
|
||||
assert "DB 查询超时" in gen_task.error_message
|
||||
assert "RuntimeError" in gen_task.error_message
|
||||
|
||||
def test_exception_sets_completed_at(self):
|
||||
"""异常时 completed_at 被设置"""
|
||||
plan = StubEditPlan(status=_StubStatus("rendering"))
|
||||
plan.config["generation_task_id"] = "gen-task-001"
|
||||
gen_task = StubGenerationTask(id="gen-task-001", status=_StubStatus("running"))
|
||||
|
||||
plan_repo = StubPlanRepo(plan)
|
||||
clip_repo = MagicMock()
|
||||
clip_repo.list_by_plan.side_effect = RuntimeError("boom")
|
||||
gen_task_repo = StubGenTaskRepo(gen_task)
|
||||
|
||||
def fake_get_repos():
|
||||
yield plan_repo, clip_repo, gen_task_repo, MagicMock()
|
||||
|
||||
bound_task = self._make_bound_task()
|
||||
|
||||
with patch(
|
||||
"worker_app.tasks.edit_plan_generation._get_repos",
|
||||
side_effect=fake_get_repos,
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
render_edit_plan(bound_task, "plan-001")
|
||||
|
||||
assert gen_task.completed_at is not None
|
||||
|
||||
def test_no_generation_task_id_does_not_crash(self):
|
||||
"""generation_task_id 为空时,异常处理不崩溃"""
|
||||
plan = StubEditPlan(status=_StubStatus("rendering"))
|
||||
plan.config = {} # 不设置 generation_task_id
|
||||
|
||||
plan_repo = StubPlanRepo(plan)
|
||||
clip_repo = MagicMock()
|
||||
clip_repo.list_by_plan.side_effect = RuntimeError("boom")
|
||||
gen_task_repo = StubGenTaskRepo() # 空 repo
|
||||
|
||||
def fake_get_repos():
|
||||
yield plan_repo, clip_repo, gen_task_repo, MagicMock()
|
||||
|
||||
bound_task = self._make_bound_task()
|
||||
|
||||
with patch(
|
||||
"worker_app.tasks.edit_plan_generation._get_repos",
|
||||
side_effect=fake_get_repos,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="retry called"):
|
||||
render_edit_plan(bound_task, "plan-001")
|
||||
|
||||
# 计划仍被标记为 failed
|
||||
assert plan.status.value == "failed"
|
||||
|
||||
def test_gen_task_update_failure_does_not_block_retry(self):
|
||||
"""更新 GenerationTask 失败时,不影响 retry 流程"""
|
||||
plan = StubEditPlan(status=_StubStatus("rendering"))
|
||||
plan.config["generation_task_id"] = "gen-task-001"
|
||||
gen_task = StubGenerationTask(id="gen-task-001", status=_StubStatus("running"))
|
||||
|
||||
plan_repo = StubPlanRepo(plan)
|
||||
clip_repo = MagicMock()
|
||||
clip_repo.list_by_plan.side_effect = RuntimeError("原始错误")
|
||||
# gen_task_repo.update 也抛异常
|
||||
gen_task_repo = MagicMock()
|
||||
gen_task_repo.get.return_value = gen_task
|
||||
gen_task_repo.update.side_effect = RuntimeError("DB 写入失败")
|
||||
|
||||
def fake_get_repos():
|
||||
yield plan_repo, clip_repo, gen_task_repo, MagicMock()
|
||||
|
||||
bound_task = self._make_bound_task()
|
||||
|
||||
with patch(
|
||||
"worker_app.tasks.edit_plan_generation._get_repos",
|
||||
side_effect=fake_get_repos,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="retry called"):
|
||||
render_edit_plan(bound_task, "plan-001")
|
||||
|
||||
# retry 被调用说明流程正确
|
||||
bound_task.retry.assert_called_once()
|
||||
|
||||
def test_already_failed_gen_task_not_overwritten(self):
|
||||
"""已经 failed 的 GenerationTask 不会被重复更新"""
|
||||
plan = StubEditPlan(status=_StubStatus("rendering"))
|
||||
plan.config["generation_task_id"] = "gen-task-001"
|
||||
gen_task = StubGenerationTask(
|
||||
id="gen-task-001",
|
||||
status=_StubStatus("failed"), # 已经是 failed
|
||||
error_message="之前的错误",
|
||||
)
|
||||
|
||||
plan_repo = StubPlanRepo(plan)
|
||||
clip_repo = MagicMock()
|
||||
clip_repo.list_by_plan.side_effect = RuntimeError("新错误")
|
||||
gen_task_repo = StubGenTaskRepo(gen_task)
|
||||
|
||||
def fake_get_repos():
|
||||
yield plan_repo, clip_repo, gen_task_repo, MagicMock()
|
||||
|
||||
bound_task = self._make_bound_task()
|
||||
|
||||
with patch(
|
||||
"worker_app.tasks.edit_plan_generation._get_repos",
|
||||
side_effect=fake_get_repos,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="retry called"):
|
||||
render_edit_plan(bound_task, "plan-001")
|
||||
|
||||
# error_message 应保持原值,不被覆盖
|
||||
assert gen_task.error_message == "之前的错误"
|
||||
@@ -331,7 +331,7 @@ class TestListPlans:
|
||||
c, repo = client
|
||||
resp = c.get("/api/v1/edit-plans?status=invalid_status")
|
||||
assert resp.status_code == 400
|
||||
assert "无效" in resp.json()["detail"]
|
||||
assert "无效的状态值" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -458,7 +458,7 @@ class TestUpdatePlan:
|
||||
|
||||
resp = c.put(f"/api/v1/edit-plans/{plan.id}", json={"status": "bogus"})
|
||||
assert resp.status_code == 400
|
||||
assert "无效" in resp.json()["detail"]
|
||||
assert "无效的状态值" in resp.json()["detail"]
|
||||
|
||||
def test_update_same_status_is_noop(self, client):
|
||||
c, repo = client
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
"""
|
||||
订阅支付回调单元测试
|
||||
|
||||
覆盖场景:
|
||||
- 正确签名的回调处理(当前实现无签名验证,验证参数合法性)
|
||||
- 缺失参数的回调被拒绝(422)
|
||||
- 重复回调的幂等性(mark_paid 对已支付账单返回 False)
|
||||
- 各种支付状态(成功处理流程)
|
||||
- 不同套餐和计费周期
|
||||
|
||||
注:当前支付回调实现较简单(无签名验证,使用查询参数),
|
||||
测试聚焦于回调处理的核心逻辑和边界情况。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.subscription import _get_plan_name, _get_plan_price, router
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Mock Billing Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockBillingRecord:
|
||||
id: str = ""
|
||||
user_id: str = ""
|
||||
plan_name: str = ""
|
||||
amount: float = 0.0
|
||||
billing_cycle: str = ""
|
||||
status: str = "pending"
|
||||
payment_method: str = ""
|
||||
payment_id: str = ""
|
||||
paid_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class MockBillingRepository:
|
||||
"""模拟的 Billing Repository,用于单元测试。"""
|
||||
|
||||
def __init__(self):
|
||||
self.records: dict[str, MockBillingRecord] = {}
|
||||
self.created_count = 0
|
||||
self.mark_paid_count = 0
|
||||
self.update_subscription_count = 0
|
||||
self.updated_subscriptions: dict[str, dict] = {}
|
||||
|
||||
def create(self, record: dict) -> MockBillingRecord:
|
||||
model = MockBillingRecord(**record)
|
||||
self.records[model.id] = model
|
||||
self.created_count += 1
|
||||
return model
|
||||
|
||||
def find_by_user(self, user_id: str, limit: int = 50) -> list[MockBillingRecord]:
|
||||
items = [r for r in self.records.values() if r.user_id == user_id]
|
||||
items.sort(key=lambda r: r.created_at or datetime.min, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def find_by_id(self, record_id: str) -> MockBillingRecord | None:
|
||||
return self.records.get(record_id)
|
||||
|
||||
def mark_paid(self, record_id: str, payment_method: str, payment_id: str) -> bool:
|
||||
self.mark_paid_count += 1
|
||||
model = self.records.get(record_id)
|
||||
if model is None or model.status == "paid":
|
||||
return False
|
||||
model.status = "paid"
|
||||
model.payment_method = payment_method
|
||||
model.payment_id = payment_id
|
||||
model.paid_at = datetime.now(timezone.utc)
|
||||
return True
|
||||
|
||||
def update_subscription_on_payment(self, user_id: str, plan: str, expires_at: datetime) -> None:
|
||||
self.update_subscription_count += 1
|
||||
self.updated_subscriptions[user_id] = {
|
||||
"plan": plan,
|
||||
"expires_at": expires_at,
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
return MockBillingRepository()
|
||||
|
||||
|
||||
def _make_client(mock_billing_repo: MockBillingRepository) -> TestClient:
|
||||
"""创建带有 mock billing repository 的 TestClient。"""
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
|
||||
# Mock SessionLocal 和 BillingRepository
|
||||
mock_session = MagicMock()
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.session.SessionLocal",
|
||||
return_value=mock_session,
|
||||
):
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository",
|
||||
return_value=mock_billing_repo,
|
||||
):
|
||||
yield TestClient(test_app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 支付成功回调测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPaymentCallbackSuccess:
|
||||
"""支付成功回调测试。"""
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_monthly_pro_payment_success(self, MockSession, MockRepo):
|
||||
"""Pro 套餐月付支付成功。"""
|
||||
mock_repo = MockBillingRepository()
|
||||
MockRepo.return_value = mock_repo
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
resp = client.post(
|
||||
"/subscription/payment-callback",
|
||||
params={
|
||||
"user_id": "user-001",
|
||||
"plan": "pro",
|
||||
"billing_cycle": "monthly",
|
||||
"amount": 299.0,
|
||||
"payment_method": "alipay",
|
||||
"payment_id": "pay_20240101_001",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert "支付成功" in data["message"]
|
||||
assert "record_id" in data
|
||||
|
||||
# 验证账单创建
|
||||
assert mock_repo.created_count == 1
|
||||
# 验证标记支付
|
||||
assert mock_repo.mark_paid_count == 1
|
||||
# 验证订阅更新
|
||||
assert mock_repo.update_subscription_count == 1
|
||||
assert "user-001" in mock_repo.updated_subscriptions
|
||||
assert mock_repo.updated_subscriptions["user-001"]["plan"] == "pro"
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_yearly_standard_payment_success(self, MockSession, MockRepo):
|
||||
"""标准版年付支付成功。"""
|
||||
mock_repo = MockBillingRepository()
|
||||
MockRepo.return_value = mock_repo
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
resp = client.post(
|
||||
"/subscription/payment-callback",
|
||||
params={
|
||||
"user_id": "user-002",
|
||||
"plan": "standard",
|
||||
"billing_cycle": "yearly",
|
||||
"amount": 999.0,
|
||||
"payment_method": "wechat",
|
||||
"payment_id": "wx_20240101_002",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert mock_repo.updated_subscriptions["user-002"]["plan"] == "standard"
|
||||
# 年付到期时间应为约 365 天后
|
||||
expires_at = mock_repo.updated_subscriptions["user-002"]["expires_at"]
|
||||
expected = datetime.now(timezone.utc) + timedelta(days=365)
|
||||
assert abs((expires_at - expected).days) <= 1
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_enterprise_payment_success(self, MockSession, MockRepo):
|
||||
"""企业版支付成功。"""
|
||||
mock_repo = MockBillingRepository()
|
||||
MockRepo.return_value = mock_repo
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
resp = client.post(
|
||||
"/subscription/payment-callback",
|
||||
params={
|
||||
"user_id": "user-003",
|
||||
"plan": "enterprise",
|
||||
"billing_cycle": "monthly",
|
||||
"amount": 999.0,
|
||||
"payment_method": "bank_transfer",
|
||||
"payment_id": "ent_20240101_003",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
assert mock_repo.updated_subscriptions["user-003"]["plan"] == "enterprise"
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_default_payment_params(self, MockSession, MockRepo):
|
||||
"""使用默认 payment_method 和空 payment_id。"""
|
||||
mock_repo = MockBillingRepository()
|
||||
MockRepo.return_value = mock_repo
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
resp = client.post(
|
||||
"/subscription/payment-callback",
|
||||
params={
|
||||
"user_id": "user-004",
|
||||
"plan": "standard",
|
||||
"billing_cycle": "monthly",
|
||||
"amount": 99.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
# 默认 payment_method 应为 alipay
|
||||
assert mock_repo.mark_paid_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. 重复回调幂等性测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPaymentCallbackIdempotency:
|
||||
"""支付回调幂等性测试。"""
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_duplicate_callback_creates_new_record(self, MockSession, MockRepo):
|
||||
"""重复回调(当前实现每次创建新账单,无幂等保护)。"""
|
||||
mock_repo = MockBillingRepository()
|
||||
MockRepo.return_value = mock_repo
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
params = {
|
||||
"user_id": "user-idem-1",
|
||||
"plan": "pro",
|
||||
"billing_cycle": "monthly",
|
||||
"amount": 299.0,
|
||||
"payment_id": "pay_dup_001",
|
||||
}
|
||||
|
||||
# 第一次回调
|
||||
resp1 = client.post("/subscription/payment-callback", params=params)
|
||||
assert resp1.status_code == 200
|
||||
|
||||
# 第二次回调(当前实现会创建新账单,不做幂等)
|
||||
resp2 = client.post("/subscription/payment-callback", params=params)
|
||||
assert resp2.status_code == 200
|
||||
# 当前实现每次都会创建新账单
|
||||
assert mock_repo.created_count == 2
|
||||
|
||||
def test_mark_paid_is_idempotent(self):
|
||||
"""mark_paid 方法对已支付账单返回 False(幂等)。"""
|
||||
repo = MockBillingRepository()
|
||||
|
||||
repo.create(
|
||||
dict(
|
||||
id="bill-001",
|
||||
user_id="u1",
|
||||
plan_name="Pro 专业版",
|
||||
amount=299.0,
|
||||
billing_cycle="monthly",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
|
||||
# 第一次标记为已支付
|
||||
result1 = repo.mark_paid("bill-001", "alipay", "pay-001")
|
||||
assert result1 is True
|
||||
assert repo.records["bill-001"].status == "paid"
|
||||
|
||||
# 第二次标记(幂等,应返回 False)
|
||||
result2 = repo.mark_paid("bill-001", "alipay", "pay-001")
|
||||
assert result2 is False
|
||||
assert repo.records["bill-001"].status == "paid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. 参数校验测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPaymentCallbackValidation:
|
||||
"""支付回调参数校验测试。"""
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_missing_user_id_returns_422(self, MockSession, MockRepo):
|
||||
"""缺少 user_id 参数返回 422。"""
|
||||
MockRepo.return_value = MockBillingRepository()
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
resp = client.post(
|
||||
"/subscription/payment-callback",
|
||||
params={"plan": "pro", "billing_cycle": "monthly", "amount": 299.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_missing_plan_returns_422(self, MockSession, MockRepo):
|
||||
"""缺少 plan 参数返回 422。"""
|
||||
MockRepo.return_value = MockBillingRepository()
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
resp = client.post(
|
||||
"/subscription/payment-callback",
|
||||
params={"user_id": "u1", "billing_cycle": "monthly", "amount": 299.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_missing_amount_returns_422(self, MockSession, MockRepo):
|
||||
"""缺少 amount 参数返回 422。"""
|
||||
MockRepo.return_value = MockBillingRepository()
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
resp = client.post(
|
||||
"/subscription/payment-callback",
|
||||
params={"user_id": "u1", "plan": "pro", "billing_cycle": "monthly"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
|
||||
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
|
||||
def test_negative_amount(self, MockSession, MockRepo):
|
||||
"""负数金额(当前实现不校验,记录此行为)。"""
|
||||
mock_repo = MockBillingRepository()
|
||||
MockRepo.return_value = mock_repo
|
||||
MockSession.return_value = MagicMock()
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/subscription")
|
||||
client = TestClient(test_app)
|
||||
|
||||
resp = client.post(
|
||||
"/subscription/payment-callback",
|
||||
params={
|
||||
"user_id": "u1",
|
||||
"plan": "pro",
|
||||
"billing_cycle": "monthly",
|
||||
"amount": -100.0,
|
||||
},
|
||||
)
|
||||
# 当前实现未校验金额正负
|
||||
assert resp.status_code in (200, 400, 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. 辅助函数测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""订阅辅助函数测试。"""
|
||||
|
||||
def test_get_plan_name_all_plans(self):
|
||||
"""所有套餐名称映射正确。"""
|
||||
assert _get_plan_name("free") == "体验版"
|
||||
assert _get_plan_name("standard") == "标准版"
|
||||
assert _get_plan_name("pro") == "专业版"
|
||||
assert _get_plan_name("enterprise") == "企业版"
|
||||
|
||||
def test_get_plan_name_unknown(self):
|
||||
"""未知套餐返回「未知套餐」。"""
|
||||
assert _get_plan_name("unknown") == "未知套餐"
|
||||
assert _get_plan_name("") == "未知套餐"
|
||||
|
||||
def test_get_plan_price_all_combinations(self):
|
||||
"""所有套餐价格映射正确。"""
|
||||
assert _get_plan_price("free", "monthly") == 0
|
||||
assert _get_plan_price("free", "yearly") == 0
|
||||
assert _get_plan_price("standard", "monthly") == 99
|
||||
assert _get_plan_price("standard", "yearly") == 999
|
||||
assert _get_plan_price("pro", "monthly") == 299
|
||||
assert _get_plan_price("pro", "yearly") == 2999
|
||||
assert _get_plan_price("enterprise", "monthly") == 999
|
||||
assert _get_plan_price("enterprise", "yearly") == 9999
|
||||
|
||||
def test_get_plan_price_unknown(self):
|
||||
"""未知组合返回 0。"""
|
||||
assert _get_plan_price("unknown", "monthly") == 0
|
||||
assert _get_plan_price("pro", "weekly") == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Mock Billing Repository 单元测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMockBillingRepository:
|
||||
"""Billing Repository 行为单元测试。"""
|
||||
|
||||
def test_create_record(self):
|
||||
"""创建账单记录。"""
|
||||
repo = MockBillingRepository()
|
||||
record = repo.create(
|
||||
dict(
|
||||
id="bill-001",
|
||||
user_id="user-001",
|
||||
plan_name="Pro 专业版",
|
||||
amount=299.0,
|
||||
billing_cycle="monthly",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
assert record.id == "bill-001"
|
||||
assert record.status == "pending"
|
||||
assert repo.created_count == 1
|
||||
|
||||
def test_find_by_id(self):
|
||||
"""按 ID 查询账单。"""
|
||||
repo = MockBillingRepository()
|
||||
repo.create(
|
||||
dict(
|
||||
id="bill-001",
|
||||
user_id="user-1",
|
||||
plan_name="Pro",
|
||||
amount=299,
|
||||
billing_cycle="monthly",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
|
||||
found = repo.find_by_id("bill-001")
|
||||
assert found is not None
|
||||
assert found.id == "bill-001"
|
||||
|
||||
not_found = repo.find_by_id("nonexistent")
|
||||
assert not_found is None
|
||||
|
||||
def test_find_by_user(self):
|
||||
"""按用户查询账单。"""
|
||||
repo = MockBillingRepository()
|
||||
repo.create(dict(id="b1", user_id="u1", plan_name="Pro", amount=299, billing_cycle="monthly", status="pending"))
|
||||
repo.create(
|
||||
dict(id="b2", user_id="u1", plan_name="Standard", amount=99, billing_cycle="monthly", status="pending")
|
||||
)
|
||||
repo.create(dict(id="b3", user_id="u2", plan_name="Pro", amount=299, billing_cycle="monthly", status="pending"))
|
||||
|
||||
user1_records = repo.find_by_user("u1")
|
||||
assert len(user1_records) == 2
|
||||
|
||||
user2_records = repo.find_by_user("u2")
|
||||
assert len(user2_records) == 1
|
||||
|
||||
def test_mark_paid_transitions_status(self):
|
||||
"""mark_paid 正确转换状态。"""
|
||||
repo = MockBillingRepository()
|
||||
repo.create(
|
||||
dict(
|
||||
id="bill-001",
|
||||
user_id="u1",
|
||||
plan_name="Pro",
|
||||
amount=299,
|
||||
billing_cycle="monthly",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
|
||||
result = repo.mark_paid("bill-001", "alipay", "pay-001")
|
||||
assert result is True
|
||||
|
||||
record = repo.find_by_id("bill-001")
|
||||
assert record.status == "paid"
|
||||
assert record.payment_method == "alipay"
|
||||
assert record.payment_id == "pay-001"
|
||||
assert record.paid_at is not None
|
||||
|
||||
def test_mark_paid_idempotent(self):
|
||||
"""mark_paid 对已支付账单幂等。"""
|
||||
repo = MockBillingRepository()
|
||||
repo.create(
|
||||
dict(
|
||||
id="bill-001",
|
||||
user_id="u1",
|
||||
plan_name="Pro",
|
||||
amount=299,
|
||||
billing_cycle="monthly",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
|
||||
repo.mark_paid("bill-001", "alipay", "pay-001")
|
||||
paid_at_first = repo.find_by_id("bill-001").paid_at
|
||||
|
||||
result = repo.mark_paid("bill-001", "alipay", "pay-001")
|
||||
assert result is False
|
||||
# paid_at 不应更新
|
||||
assert repo.find_by_id("bill-001").paid_at == paid_at_first
|
||||
|
||||
def test_mark_paid_nonexistent_returns_false(self):
|
||||
"""标记不存在的账单返回 False。"""
|
||||
repo = MockBillingRepository()
|
||||
result = repo.mark_paid("nonexistent", "alipay", "pay-001")
|
||||
assert result is False
|
||||
|
||||
def test_update_subscription_on_payment(self):
|
||||
"""支付成功后更新订阅。"""
|
||||
repo = MockBillingRepository()
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
|
||||
repo.update_subscription_on_payment("user-001", "pro", expires)
|
||||
|
||||
assert repo.update_subscription_count == 1
|
||||
assert "user-001" in repo.updated_subscriptions
|
||||
sub = repo.updated_subscriptions["user-001"]
|
||||
assert sub["plan"] == "pro"
|
||||
assert sub["status"] == "active"
|
||||
assert sub["expires_at"] == expires
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -177,14 +177,12 @@ class TestGeneratedVideosAPIAvailability:
|
||||
|
||||
def test_generated_videos_routes_registered(self):
|
||||
"""成片库路由已注册到 router。"""
|
||||
from app.api.routes.generated_videos import router as gv_router
|
||||
from apps.api.app.api.router import api_router
|
||||
|
||||
# 直接检查 generated_videos router 自身注册的路由
|
||||
paths = [r.path for r in gv_router.routes if hasattr(r, "path")]
|
||||
assert len(paths) > 0, "generated_videos router 没有注册任何路由"
|
||||
# 验证关键端点存在
|
||||
assert "" in paths, "列表端点不存在"
|
||||
assert "/{video_id}" in paths, "详情端点不存在"
|
||||
# 检查 router 包含 generated-videos 路径
|
||||
routes = [r for r in api_router.routes if hasattr(r, "path")]
|
||||
gv_routes = [r for r in routes if "generated-videos" in r.path]
|
||||
assert len(gv_routes) > 0, "generated-videos 路由未注册"
|
||||
|
||||
def test_generated_videos_list_endpoint_exists(self):
|
||||
"""GET /generated-videos 端点存在。"""
|
||||
|
||||
@@ -4,17 +4,8 @@
|
||||
而 worker_app.db 会在导入时调用 ensure_database_exists() 尝试连接 PostgreSQL。
|
||||
因此必须在 @patch 装饰器解析模块路径之前,将 worker_app.db 预注入 sys.modules。
|
||||
|
||||
注意:production code 使用 VoiceCloneWorkflowService(非直接 CosyVoiceService),
|
||||
Celery bind=True 任务的底层函数签名为 (self, profile_id),
|
||||
CosyVoiceService 在 voice_clone.py 中被实例化传入 workflow,必须 mock 防止真实初始化。
|
||||
|
||||
跨环境兼容:
|
||||
Python 3.13 + Celery 5.4.0 → import 返回 Celery Proxy
|
||||
→ _get_current_object() 返回 Task 实例 → .run 是 bound method(self 已绑定)
|
||||
→ 调用方式:task.run(profile_id),retry mock 在 task.run.retry
|
||||
Python 3.10 + Celery 5.4.0 → import 返回原始函数(装饰器未生效)
|
||||
→ 签名 (self, profile_id),需手动传 mock_self
|
||||
→ 调用方式:func(mock_self, profile_id),retry mock 在 mock_self.retry
|
||||
Celery 5.x 中 @task(bind=True) 装饰后,task.run 是绑定方法(self 已绑定),
|
||||
直接调用 task(profile_id) 即可,不需要手动传 self。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,121 +31,83 @@ from celery.exceptions import Retry
|
||||
|
||||
from packages.application.cosyvoice_service import (
|
||||
CosyVoiceError,
|
||||
CosyVoiceService,
|
||||
CosyVoiceTimeoutError,
|
||||
)
|
||||
from packages.application.voice_clone.use_cases import VoiceCloneNotFoundError
|
||||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
||||
|
||||
|
||||
def _make_mock_profile(
|
||||
def _make_profile(
|
||||
*,
|
||||
voice_id: str = "voice-xyz",
|
||||
status: str = "ready",
|
||||
) -> MagicMock:
|
||||
"""创建测试用 mock profile。"""
|
||||
profile = MagicMock()
|
||||
profile.voice_id = voice_id
|
||||
status: VoiceCloneStatus = VoiceCloneStatus.PROCESSING,
|
||||
metadata: dict | None = None,
|
||||
) -> VoiceCloneProfile:
|
||||
"""创建测试用 VoiceCloneProfile。"""
|
||||
if metadata is None:
|
||||
metadata = {"cosyvoice_task_id": "task-abc"}
|
||||
profile = VoiceCloneProfile.create(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
max_retries=3,
|
||||
metadata=metadata,
|
||||
)
|
||||
profile.status = status
|
||||
return profile
|
||||
|
||||
|
||||
def _resolve_task(task_obj):
|
||||
"""解析 Celery 任务对象,返回 (callable, mock_self_or_none)。
|
||||
|
||||
跨环境兼容 Celery Proxy / Task 实例 / 原始函数三种情况。
|
||||
|
||||
Returns:
|
||||
tuple: (callable, mock_self)
|
||||
- Proxy/Task: callable 是 bound method task.run,mock_self=None
|
||||
- 原始函数: callable 是原始函数,mock_self 需由调用方提供
|
||||
"""
|
||||
# Case 1: Celery Proxy → 提取 Task 实例的 .run(bound method)
|
||||
if hasattr(task_obj, "_get_current_object"):
|
||||
real_task = task_obj._get_current_object()
|
||||
return real_task.run, None
|
||||
# Case 2: Celery Task 实例(非 Proxy)
|
||||
if hasattr(task_obj, "run") and hasattr(task_obj, "retry"):
|
||||
return task_obj.run, None
|
||||
# Case 3: 原始函数(CI 环境中装饰器未生效)
|
||||
return task_obj, MagicMock()
|
||||
|
||||
|
||||
# ── 成功场景 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProcessVoiceCloneSuccess:
|
||||
"""测试成功场景。"""
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.SessionLocal")
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_success(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
mock_session_local: MagicMock,
|
||||
) -> None:
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
def test_process_voice_clone_success(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None:
|
||||
"""克隆成功:轮询返回 voice_id,profile 标记为 ready。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
mock_service = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile()
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_result = _make_mock_profile(voice_id="voice-xyz")
|
||||
mock_workflow.poll_and_process_clone.return_value = mock_result
|
||||
mock_service.poll_clone_task.return_value = {"voice_id": "voice-xyz"}
|
||||
mock_service_cls.return_value = mock_service
|
||||
|
||||
mock_session_local.return_value = mock_session
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
func, mock_self = _resolve_task(process_voice_clone)
|
||||
args = (mock_self, "profile-123") if mock_self else ("profile-123",)
|
||||
result = func(*args)
|
||||
# bind=True → run 是绑定方法,直接调用 task(profile_id)
|
||||
result = process_voice_clone("profile-123")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["voice_id"] == "voice-xyz"
|
||||
mock_workflow.poll_and_process_clone.assert_called_once_with(
|
||||
"profile-123",
|
||||
timeout=300,
|
||||
)
|
||||
mock_service.poll_clone_task.assert_called_once_with("task-abc", timeout=300)
|
||||
mock_session.commit.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.SessionLocal")
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_profile_not_found(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
mock_session_local: MagicMock,
|
||||
) -> None:
|
||||
"""profile 不存在时 workflow 抛 VoiceCloneNotFoundError,返回 failed。"""
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
def test_process_voice_clone_profile_not_found(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None:
|
||||
"""profile 不存在时返回 failed。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
|
||||
mock_repo.get.return_value = None
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = VoiceCloneNotFoundError("Voice clone nonexistent not found")
|
||||
|
||||
mock_session_local.return_value = mock_session
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
func, mock_self = _resolve_task(process_voice_clone)
|
||||
args = (mock_self, "nonexistent") if mock_self else ("nonexistent",)
|
||||
result = func(*args)
|
||||
result = process_voice_clone("nonexistent")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["error"].lower()
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
@@ -164,49 +117,29 @@ class TestProcessVoiceCloneSuccess:
|
||||
class TestProcessVoiceCloneTimeout:
|
||||
"""测试超时场景。"""
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.SessionLocal")
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_timeout_retries(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
mock_session_local: MagicMock,
|
||||
) -> None:
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
def test_process_voice_clone_timeout_retries(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None:
|
||||
"""超时时调用 self.retry() 进行重试,Retry 异常向上传播。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
mock_service = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile()
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceTimeoutError("任务超时")
|
||||
mock_service.poll_clone_task.side_effect = CosyVoiceTimeoutError("任务超时")
|
||||
mock_service_cls.return_value = mock_service
|
||||
|
||||
mock_session_local.return_value = mock_session
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
func, mock_self = _resolve_task(process_voice_clone)
|
||||
|
||||
# 设置 retry mock:根据环境不同,retry 在不同对象上
|
||||
if mock_self is None:
|
||||
# Proxy/Task 环境:retry 在 Task 实例上(func 是 bound method task.run)
|
||||
real_task = process_voice_clone._get_current_object()
|
||||
mock_retry = MagicMock()
|
||||
mock_retry.side_effect = Retry("retrying")
|
||||
with patch.object(real_task, "retry", mock_retry):
|
||||
with pytest.raises(Retry):
|
||||
func("profile-123")
|
||||
mock_retry.assert_called_once()
|
||||
else:
|
||||
# 原始函数环境:retry 在 mock_self 上
|
||||
mock_self.retry.side_effect = Retry("retrying")
|
||||
# mock task.retry 使其抛出 Retry(模拟 Celery 行为)
|
||||
with patch.object(process_voice_clone, "retry", side_effect=Retry("retrying")):
|
||||
with pytest.raises(Retry):
|
||||
func(mock_self, "profile-123")
|
||||
mock_self.retry.assert_called_once()
|
||||
process_voice_clone("profile-123")
|
||||
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
@@ -218,104 +151,77 @@ class TestProcessVoiceCloneTimeout:
|
||||
class TestProcessVoiceCloneFailure:
|
||||
"""测试失败场景。"""
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.SessionLocal")
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_cosyvoice_error(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
mock_session_local: MagicMock,
|
||||
) -> None:
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
def test_process_voice_clone_cosyvoice_error(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None:
|
||||
"""CosyVoice 错误:profile 标记为 failed。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
mock_service = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile()
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceError("克隆失败")
|
||||
mock_service.poll_clone_task.side_effect = CosyVoiceError("克隆失败")
|
||||
mock_service_cls.return_value = mock_service
|
||||
|
||||
mock_session_local.return_value = mock_session
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
func, mock_self = _resolve_task(process_voice_clone)
|
||||
args = (mock_self, "profile-123") if mock_self else ("profile-123",)
|
||||
result = func(*args)
|
||||
result = process_voice_clone("profile-123")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "克隆失败" in result["error"]
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.SessionLocal")
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_unexpected_error(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
mock_session_local: MagicMock,
|
||||
) -> None:
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
def test_process_voice_clone_unexpected_error(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None:
|
||||
"""意外异常:profile 标记为 failed。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
mock_service = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile()
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = RuntimeError("未知错误")
|
||||
mock_service.poll_clone_task.side_effect = RuntimeError("未知错误")
|
||||
mock_service_cls.return_value = mock_service
|
||||
|
||||
mock_session_local.return_value = mock_session
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
func, mock_self = _resolve_task(process_voice_clone)
|
||||
args = (mock_self, "profile-123") if mock_self else ("profile-123",)
|
||||
result = func(*args)
|
||||
result = process_voice_clone("profile-123")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "未知错误" in result["error"]
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@patch("worker_app.tasks.voice_clone.SessionLocal")
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
@patch("worker_app.tasks.voice_clone.VoiceCloneWorkflowService")
|
||||
@patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository")
|
||||
def test_process_voice_clone_no_task_id(
|
||||
self,
|
||||
mock_repo_cls: MagicMock,
|
||||
mock_workflow_cls: MagicMock,
|
||||
mock_cosy_cls: MagicMock,
|
||||
mock_session_local: MagicMock,
|
||||
) -> None:
|
||||
@patch("worker_app.tasks.voice_clone.CosyVoiceService")
|
||||
def test_process_voice_clone_no_task_id(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None:
|
||||
"""metadata 中没有 cosyvoice_task_id 时返回 failed。"""
|
||||
mock_session = MagicMock()
|
||||
mock_repo = MagicMock()
|
||||
mock_workflow = MagicMock()
|
||||
|
||||
# 显式传入空 dict,确保没有 cosyvoice_task_id
|
||||
profile = _make_profile(metadata={})
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
mock_workflow_cls.return_value = mock_workflow
|
||||
|
||||
mock_workflow.poll_and_process_clone.side_effect = CosyVoiceError("missing task_id")
|
||||
|
||||
mock_session_local.return_value = mock_session
|
||||
_mock_db_module.SessionLocal.return_value = mock_session
|
||||
|
||||
from worker_app.tasks.voice_clone import process_voice_clone
|
||||
|
||||
func, mock_self = _resolve_task(process_voice_clone)
|
||||
args = (mock_self, "profile-123") if mock_self else ("profile-123",)
|
||||
result = func(*args)
|
||||
result = process_voice_clone("profile-123")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "task_id" in result["error"]
|
||||
mock_session.rollback.assert_called_once()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user