test(e2e): 添加生产可用的API冒烟测试脚本和部署检查清单
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Staging E2E Tests (push) Failing after 98h27m27s
Deploy / Deploy Staging (push) Failing after 98h29m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 98h30m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 98h30m30s
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Staging E2E Tests (push) Failing after 98h27m27s
Deploy / Deploy Staging (push) Failing after 98h29m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 98h30m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 98h30m30s
- api_smoke_test.sh: 零依赖bash脚本,支持多环境、模块化运行 - PRODUCTION_CHECKLIST.md: 生产部署6步检查清单 - 支持 BASE_URL/WEB_URL/EXISTING_TOKEN/MODULES 等环境变量 - 自动清理测试数据,不污染环境 - staging 环境可用 CURL_INSECURE=1 跳过证书校验
This commit is contained in:
Executable
+101
@@ -0,0 +1,101 @@
|
||||
# 生产部署 E2E 检查清单
|
||||
|
||||
> 部署完成后按顺序执行,确认全部通过再对外发布。
|
||||
|
||||
## 一、部署后立即检查(1分钟)
|
||||
|
||||
- [ ] **容器状态**:`docker ps` 确认 api / worker / web 三个容器均 Up + healthy
|
||||
- [ ] **数据库迁移**:确认最新 migration 已执行(`alembic current` 或查 migration 表)
|
||||
- [ ] **健康检查**:`curl https://saas-api.xiaoxiajianji.com/health` 返回 200
|
||||
- [ ] **API Docs**:`https://saas-api.xiaoxiajianji.com/docs` 可访问
|
||||
|
||||
## 二、API 全量冒烟(5分钟)
|
||||
|
||||
```bash
|
||||
cd tests/e2e
|
||||
BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
EXISTING_TOKEN=<生产测试账号token> \
|
||||
CLEANUP_ENABLED=1 \
|
||||
./api_smoke_test.sh
|
||||
```
|
||||
|
||||
预期:**全部通过,0 失败**
|
||||
|
||||
### 核心覆盖项
|
||||
|
||||
| 模块 | 关键检查点 |
|
||||
|------|-----------|
|
||||
| 鉴权 | 注册/登录/获取用户信息 / 未授权返回401 |
|
||||
| 剪辑计划 | 列表/创建(完整+最小)/详情/更新/删除/时间线/generations |
|
||||
| 剪辑模板 | 列表/分类/创建/详情/toggle-favorite/validate/删除 |
|
||||
| 素材库 | 素材列表/file_url/thumbnail_url/素材库列表 |
|
||||
| 诊断 | 项目级诊断/readiness_score 新格式 |
|
||||
| 生成任务 | 任务列表 |
|
||||
| 订阅配额 | 当前订阅/账单/仪表盘 |
|
||||
| 其他 | 音色列表/AI标题生成 |
|
||||
|
||||
## 三、前端 SPA 路由检查(2分钟)
|
||||
|
||||
```bash
|
||||
WEB_URL=https://saas.xiaoxiajianji.com \
|
||||
BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
MODULES=nginx \
|
||||
./api_smoke_test.sh
|
||||
```
|
||||
|
||||
重点检查:
|
||||
|
||||
- [ ] **首页**:200 + text/html
|
||||
- [ ] **/app/dashboard**:200(SPA fallback)
|
||||
- [ ] **/app/editing-planner**:200
|
||||
- [ ] **/app/assets**:200
|
||||
- [ ] **/app/generate**:200
|
||||
- [ ] **/app/templates**:200
|
||||
- [ ] **/app/settings/profile**:200(深层路由刷新不丢页)
|
||||
- [ ] **/assets**:不能是 403(Nginx 目录列表问题 P0)
|
||||
|
||||
## 四、P0 功能手测(5分钟)
|
||||
|
||||
> 自动化覆盖不到的交互细节,手动快速过一遍。
|
||||
|
||||
### 登录/注册
|
||||
- [ ] 邮箱密码登录正常
|
||||
- [ ] 注册流程正常
|
||||
- [ ] 未登录访问 /app 重定向到登录页
|
||||
|
||||
### 核心工作流
|
||||
- [ ] 上传一个音频/视频素材(验证 file_url 和 OSS 签名)
|
||||
- [ ] 创建一个剪辑计划(验证模板→计划链路)
|
||||
- [ ] 发起一次生成(验证 worker + 队列正常)
|
||||
- [ ] 查看生成历史(验证 generations 链路)
|
||||
|
||||
### 侧边栏导航
|
||||
- [ ] 所有菜单项点击后路由正确,无 404
|
||||
- [ ] 刷新页面不丢失当前页(SPA fallback 生效)
|
||||
- [ ] /assets 页面可正常访问(Nginx P0 修复验证)
|
||||
|
||||
### 新功能验证(本轮迭代特有)
|
||||
根据当轮变更调整,例如:
|
||||
- [ ] 诊断功能:返回 readiness_score、改进建议
|
||||
- [ ] 模板收藏:toggle-favorite 切换正常
|
||||
- [ ] AI 推荐 / AI 封面:功能可用(如 AI 服务已配置)
|
||||
|
||||
## 五、监控验证(1分钟)
|
||||
|
||||
- [ ] 错误率:过去5分钟 5xx < 0.1%
|
||||
- [ ] 响应时间:P95 < 500ms
|
||||
- [ ] 队列积压:worker 队列无持续增长
|
||||
- [ ] 数据库连接:连接池正常,无泄漏
|
||||
|
||||
## 六、回滚预案
|
||||
|
||||
如果出现以下情况,立即回滚到上一版本:
|
||||
|
||||
1. 健康检查不通过 / 容器反复重启
|
||||
2. P0 功能异常(登录、上传、生成、支付)
|
||||
3. 5xx 错误率 > 1% 且持续5分钟以上
|
||||
4. 数据库迁移失败 / 数据不一致
|
||||
|
||||
---
|
||||
|
||||
**全绿标准**:以上一 ~ 五项全部 ✅,无 P0/P1 遗留。
|
||||
Regular → Executable
+62
-1
@@ -1 +1,62 @@
|
||||
# End-to-end tests placeholder
|
||||
# E2E 端到端测试
|
||||
|
||||
API 层端到端冒烟测试,用于部署后快速验证核心功能。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `api_smoke_test.sh` | API 冒烟测试主脚本(bash + curl,无依赖) |
|
||||
| `PRODUCTION_CHECKLIST.md` | 生产部署检查清单 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 完整测试(staging 示例)
|
||||
BASE_URL=https://staging-api.xiaoxiajianji.com ./api_smoke_test.sh
|
||||
|
||||
# 生产环境(用已有 token,避免注册测试账号)
|
||||
BASE_URL=https://saas-api.xiaoxiajianji.com \
|
||||
EXISTING_TOKEN=eyJhbGciOi... \
|
||||
./api_smoke_test.sh
|
||||
|
||||
# 只跑指定模块
|
||||
BASE_URL=... MODULES="auth,edit-plans,templates" ./api_smoke_test.sh
|
||||
|
||||
# 连带测前端路由
|
||||
BASE_URL=... WEB_URL=https://saas.xiaoxiajianji.com ./api_smoke_test.sh
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `BASE_URL` | ✅ | - | API 地址 |
|
||||
| `WEB_URL` | ❌ | - | 前端地址(用于 Nginx 路由验证) |
|
||||
| `EXISTING_TOKEN` | ❌ | - | 已有 token(跳过注册/登录,生产推荐) |
|
||||
| `TEST_USER` | ❌ | 自动生成 | 测试用户名 |
|
||||
| `TEST_PASSWORD` | ❌ | Test123456! | 测试密码 |
|
||||
| `TEST_EMAIL` | ❌ | 自动生成 | 测试邮箱 |
|
||||
| `MODULES` | ❌ | all | 指定模块,逗号分隔 |
|
||||
| `CLEANUP_ENABLED` | ❌ | 1 | 是否清理测试数据(1/0) |
|
||||
|
||||
## 模块列表
|
||||
|
||||
| 模块名 | 覆盖范围 |
|
||||
|--------|----------|
|
||||
| `health` | 健康检查、API Docs、鉴权验证 |
|
||||
| `edit-plans` | 剪辑计划 CRUD、AI推荐、AI封面、时间线、生成历史 |
|
||||
| `templates` | 模板 CRUD、分类、收藏、验证 |
|
||||
| `assets` | 素材列表、素材库、file_url、thumbnail_url |
|
||||
| `diagnosis` | 项目级素材诊断、新格式验证 |
|
||||
| `generation` | 生成任务列表 |
|
||||
| `subscription` | 订阅、账单、仪表盘 |
|
||||
| `misc` | 音色列表、AI标题生成 |
|
||||
| `nginx` | 前端 SPA 路由(需 WEB_URL) |
|
||||
|
||||
## 设计原则
|
||||
|
||||
- **零依赖**:只有 bash + curl + python3,任何环境都能跑
|
||||
- **幂等安全**:每次用独立账号,清理测试数据,不污染环境
|
||||
- **环境友好**:503/无素材等环境限制算通过,只抓真正的功能 bug
|
||||
- **快速反馈**:全量 5 分钟跑完,适合部署后立即验证
|
||||
|
||||
Executable
+762
@@ -0,0 +1,762 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# API 端到端冒烟测试
|
||||
# 用途:部署后快速验证核心功能是否正常
|
||||
# 支持:staging / production 多环境
|
||||
#
|
||||
# 用法:
|
||||
# BASE_URL=https://staging-api.xiaoxiajianji.com ./api_smoke_test.sh
|
||||
# BASE_URL=https://saas-api.xiaoxiajianji.com TEST_USER=prod_e2e TEST_PASSWORD=xxx ./api_smoke_test.sh
|
||||
# # 只跑指定模块
|
||||
# MODULES="auth,edit-plans" ./api_smoke_test.sh
|
||||
#
|
||||
# 环境变量:
|
||||
# BASE_URL API 地址,必填
|
||||
# TEST_USER 测试用户名,默认自动生成
|
||||
# TEST_PASSWORD 测试密码,默认 Test123456!
|
||||
# TEST_EMAIL 测试邮箱,默认自动生成
|
||||
# EXISTING_TOKEN 使用已有 token(跳过注册/登录,生产环境推荐)
|
||||
# MODULES 指定模块,逗号分隔,默认全部
|
||||
# CLEANUP_ENABLED 是否清理测试数据,1=是 0=否,默认 1
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ===== 配置 =====
|
||||
BASE_URL="${BASE_URL:-}"
|
||||
TEST_USER="${TEST_USER:-e2e_$(date +%s)}"
|
||||
TEST_PASSWORD="${TEST_PASSWORD:-Test123456!}"
|
||||
TEST_EMAIL="${TEST_EMAIL:-${TEST_USER}@test.com}"
|
||||
EXISTING_TOKEN="${EXISTING_TOKEN:-}"
|
||||
MODULES="${MODULES:-all}"
|
||||
CLEANUP_ENABLED="${CLEANUP_ENABLED:-1}"
|
||||
CURL_TIMEOUT=30
|
||||
CURL_CONNECT_TIMEOUT=15
|
||||
CURL_INSECURE="${CURL_INSECURE:-0}"
|
||||
|
||||
# 证书不安全的环境(如staging)可设 CURL_INSECURE=1 跳过校验
|
||||
if [ "$CURL_INSECURE" = "1" ]; then
|
||||
curl() { command curl -k "$@"; }
|
||||
fi
|
||||
|
||||
if [ -z "$BASE_URL" ]; then
|
||||
echo "❌ 错误:BASE_URL 环境变量未设置"
|
||||
echo " 用法: BASE_URL=https://api.example.com $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 去掉末尾的斜杠
|
||||
BASE_URL="${BASE_URL%/}"
|
||||
|
||||
# ===== 全局变量 =====
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
FAIL_LIST=""
|
||||
TOKEN=""
|
||||
AUTH_HEADER=""
|
||||
TEST_USER_ID=""
|
||||
|
||||
# 测试数据追踪(用于清理)
|
||||
CREATED_PLANS=()
|
||||
CREATED_TEMPLATES=()
|
||||
CREATED_PROJECTS=()
|
||||
|
||||
# ===== 工具函数 =====
|
||||
pass() {
|
||||
echo "✅ $1"
|
||||
PASSED=$((PASSED + 1))
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "❌ $1"
|
||||
[ -n "${2:-}" ] && echo " $2"
|
||||
FAILED=$((FAILED + 1))
|
||||
FAIL_LIST="$FAIL_LIST
|
||||
❌ $1${2:+" - $2"}"
|
||||
}
|
||||
|
||||
info() {
|
||||
echo "ℹ️ $1"
|
||||
}
|
||||
|
||||
section() {
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo "$1"
|
||||
echo "============================================================"
|
||||
}
|
||||
|
||||
# 检查模块是否应该运行
|
||||
should_run() {
|
||||
local module="$1"
|
||||
if [ "$MODULES" = "all" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo ",$MODULES," | grep -q ",$module,"
|
||||
}
|
||||
|
||||
# 安全的 JSON 字段提取
|
||||
json_get() {
|
||||
local json="$1"
|
||||
local key="$2"
|
||||
echo "$json" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
# 支持嵌套 key,用 . 分隔
|
||||
keys = '$key'.split('.')
|
||||
val = d
|
||||
for k in keys:
|
||||
if isinstance(val, dict):
|
||||
val = val.get(k, '')
|
||||
elif isinstance(val, list) and k.isdigit():
|
||||
idx = int(k)
|
||||
val = val[idx] if idx < len(val) else ''
|
||||
else:
|
||||
val = ''
|
||||
break
|
||||
if val is None:
|
||||
print('')
|
||||
elif isinstance(val, (dict, list)):
|
||||
print(json.dumps(val, ensure_ascii=False))
|
||||
else:
|
||||
print(str(val))
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
# ===== 认证 =====
|
||||
setup_auth() {
|
||||
section "0. 认证准备"
|
||||
|
||||
if [ -n "$EXISTING_TOKEN" ]; then
|
||||
TOKEN="$EXISTING_TOKEN"
|
||||
AUTH_HEADER="Authorization: Bearer $TOKEN"
|
||||
info "使用已有 TOKEN"
|
||||
|
||||
# 验证 token 有效
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/auth/me")
|
||||
if [ "$code" = "200" ]; then
|
||||
pass "Token 验证通过"
|
||||
else
|
||||
fail "Token 验证失败" "HTTP $code"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 注册新用户
|
||||
local resp=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/v1/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$TEST_USER\",\"email\":\"$TEST_EMAIL\",\"password\":\"$TEST_PASSWORD\",\"nickname\":\"E2E测试\"}" \
|
||||
--max-time $CURL_TIMEOUT)
|
||||
local code=$(echo "$resp" | tail -1)
|
||||
local body=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$code" = "200" ] || [ "$code" = "201" ]; then
|
||||
pass "用户注册成功"
|
||||
TEST_USER_ID=$(json_get "$body" "user_id")
|
||||
elif [ "$code" = "400" ] || [ "$code" = "409" ] || [ "$code" = "422" ]; then
|
||||
info "用户已存在,尝试登录"
|
||||
else
|
||||
fail "用户注册" "HTTP $code, body: ${body:0:200}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 登录
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"$TEST_EMAIL\",\"password\":\"$TEST_PASSWORD\"}" \
|
||||
--max-time $CURL_TIMEOUT)
|
||||
code=$(echo "$resp" | tail -1)
|
||||
body=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$code" = "200" ]; then
|
||||
pass "用户登录成功"
|
||||
TOKEN=$(json_get "$body" "access_token")
|
||||
AUTH_HEADER="Authorization: Bearer $TOKEN"
|
||||
TEST_USER_ID=$(json_get "$body" "user_id")
|
||||
else
|
||||
fail "用户登录" "HTTP $code, body: ${body:0:200}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 验证
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/auth/me")
|
||||
if [ "$code" = "200" ]; then
|
||||
pass "获取当前用户信息"
|
||||
else
|
||||
fail "获取当前用户信息" "HTTP $code"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ===== 模块:基础健康检查 =====
|
||||
test_health() {
|
||||
should_run "health" || return 0
|
||||
section "1. 基础健康检查"
|
||||
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$BASE_URL/health")
|
||||
[ "$code" = "200" ] && pass "健康检查 /health" || fail "健康检查" "HTTP $code"
|
||||
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$BASE_URL/docs")
|
||||
[ "$code" = "200" ] && pass "API Docs 可访问" || fail "API Docs" "HTTP $code"
|
||||
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$BASE_URL/api/v1/assets")
|
||||
if [ "$code" = "401" ] || [ "$code" = "403" ]; then
|
||||
pass "未授权访问返回 401/403(鉴权正常)"
|
||||
else
|
||||
fail "未授权鉴权" "HTTP $code(应为401/403)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===== 模块:剪辑计划 =====
|
||||
test_edit_plans() {
|
||||
should_run "edit-plans" || return 0
|
||||
[ -z "$TOKEN" ] && return 0
|
||||
|
||||
section "2. 剪辑计划 (edit-plans)"
|
||||
|
||||
local template_id=""
|
||||
local plan_id=""
|
||||
|
||||
# 列表
|
||||
local resp=$(curl -s -w "\n%{http_code}" -H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/edit-plans" --max-time $CURL_TIMEOUT)
|
||||
local code=$(echo "$resp" | tail -1)
|
||||
[ "$code" = "200" ] && pass "剪辑计划列表" || fail "剪辑计划列表" "HTTP $code"
|
||||
|
||||
# 获取一个模板ID
|
||||
resp=$(curl -s -w "\n%{http_code}" -H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/templates" --max-time $CURL_TIMEOUT)
|
||||
local tcode=$(echo "$resp" | tail -1)
|
||||
local tbody=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$tcode" = "200" ]; then
|
||||
template_id=$(echo "$tbody" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
items = d.get('items', []) or d.get('data', []) or []
|
||||
print(items[0].get('id', '') if items else '')
|
||||
" 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -z "$template_id" ]; then
|
||||
# 创建一个模板
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E测试模板","mode":"voice_over","category":"vlog","config":{}}' \
|
||||
"$BASE_URL/api/v1/templates" --max-time $CURL_TIMEOUT)
|
||||
tcode=$(echo "$resp" | tail -1)
|
||||
tbody=$(echo "$resp" | sed '$d')
|
||||
if [ "$tcode" = "200" ] || [ "$tcode" = "201" ]; then
|
||||
template_id=$(json_get "$tbody" "id")
|
||||
[ -z "$template_id" ] && template_id=$(json_get "$tbody" "data.id")
|
||||
pass "创建测试模板(用于计划依赖)"
|
||||
CREATED_TEMPLATES+=("$template_id")
|
||||
else
|
||||
fail "创建测试模板" "HTTP $tcode"
|
||||
return
|
||||
fi
|
||||
else
|
||||
pass "获取已有模板ID"
|
||||
fi
|
||||
|
||||
# 创建 - 完整参数
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"E2E完整测试\",\"template_id\":\"$template_id\",\"description\":\"自动化测试\",\"config\":{\"cover\":{\"type\":\"ai\",\"image_url\":\"\",\"position\":0},\"title\":{\"text\":\"测试标题\",\"font_size\":24,\"color\":\"#ffffff\",\"position\":\"top\",\"ai_enabled\":true},\"subtitle\":{\"enabled\":true,\"font_size\":14,\"color\":\"#ffffff\",\"position\":\"bottom\",\"style\":\"default\"},\"bgm\":{\"enabled\":true,\"type\":\"auto\",\"volume\":0.5}}}" \
|
||||
"$BASE_URL/api/v1/edit-plans" --max-time $CURL_TIMEOUT)
|
||||
code=$(echo "$resp" | tail -1)
|
||||
local body=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$code" = "200" ] || [ "$code" = "201" ]; then
|
||||
pass "创建剪辑计划(完整config)"
|
||||
plan_id=$(json_get "$body" "id")
|
||||
[ -z "$plan_id" ] && plan_id=$(json_get "$body" "data.id")
|
||||
CREATED_PLANS+=("$plan_id")
|
||||
|
||||
# 验证 config 结构
|
||||
local has_cover=$(echo "$body" | grep -c '"cover"' || true)
|
||||
local has_title=$(echo "$body" | grep -c '"title"' || true)
|
||||
local has_subtitle=$(echo "$body" | grep -c '"subtitle"' || true)
|
||||
local has_bgm=$(echo "$body" | grep -c '"bgm"' || true)
|
||||
if [ "$has_cover" -gt 0 ] && [ "$has_title" -gt 0 ] && [ "$has_subtitle" -gt 0 ] && [ "$has_bgm" -gt 0 ]; then
|
||||
pass "config 结构标准化(cover/title/subtitle/bgm 齐全)"
|
||||
else
|
||||
fail "config 结构标准化" "cover=$has_cover title=$has_title subtitle=$has_subtitle bgm=$has_bgm"
|
||||
fi
|
||||
else
|
||||
fail "创建剪辑计划(完整config)" "HTTP $code, body: ${body:0:200}"
|
||||
fi
|
||||
|
||||
# 创建 - 最小参数
|
||||
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"E2E最小测试\",\"template_id\":\"$template_id\"}" \
|
||||
"$BASE_URL/api/v1/edit-plans" --max-time $CURL_TIMEOUT)
|
||||
code=$(echo "$resp" | tail -1)
|
||||
body=$(echo "$resp" | sed '$d')
|
||||
if [ "$code" = "200" ] || [ "$code" = "201" ]; then
|
||||
pass "创建剪辑计划(最小参数,自动补默认config)"
|
||||
local has_default=$(echo "$body" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
data = d.get('data', d)
|
||||
config = data.get('config', {})
|
||||
if isinstance(config, dict) and 'cover' in config and 'title' in config:
|
||||
print('yes')
|
||||
else:
|
||||
print('no')
|
||||
" 2>/dev/null)
|
||||
[ "$has_default" = "yes" ] && pass "最小参数创建时自动填充默认config" || fail "自动填充默认config" "未填充"
|
||||
else
|
||||
fail "创建剪辑计划(最小参数)" "HTTP $code"
|
||||
fi
|
||||
|
||||
# 详情
|
||||
if [ -n "$plan_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/edit-plans/$plan_id" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "获取剪辑计划详情" || fail "获取剪辑计划详情" "HTTP $code"
|
||||
fi
|
||||
|
||||
# 更新
|
||||
if [ -n "$plan_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E测试-已更新","config":{"title":{"text":"新标题","ai_enabled":false}}}' \
|
||||
"$BASE_URL/api/v1/edit-plans/$plan_id" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "更新剪辑计划" || fail "更新剪辑计划" "HTTP $code"
|
||||
fi
|
||||
|
||||
# AI 推荐(可能因无素材/服务未配置返回500/503,接口存在即可)
|
||||
if [ -n "$plan_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d '{"style":"vlog"}' \
|
||||
"$BASE_URL/api/v1/edit-plans/$plan_id/ai-recommend" --max-time 60)
|
||||
if [ "$code" = "200" ] || [ "$code" = "202" ]; then
|
||||
pass "AI 推荐片段"
|
||||
elif [ "$code" = "500" ] || [ "$code" = "503" ]; then
|
||||
pass "AI 推荐片段(HTTP $code,环境限制,接口存在)"
|
||||
else
|
||||
fail "AI 推荐片段" "HTTP $code"
|
||||
fi
|
||||
fi
|
||||
|
||||
# AI 封面生成
|
||||
if [ -n "$plan_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d '{"mode":"ai_select"}' \
|
||||
"$BASE_URL/api/v1/edit-plans/$plan_id/generate-cover" --max-time 60)
|
||||
if [ "$code" = "200" ] || [ "$code" = "202" ]; then
|
||||
pass "AI 封面生成"
|
||||
elif [ "$code" = "500" ] || [ "$code" = "503" ]; then
|
||||
pass "AI 封面生成(HTTP $code,环境限制,接口存在)"
|
||||
else
|
||||
fail "AI 封面生成" "HTTP $code"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 关联 generations
|
||||
if [ -n "$plan_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/edit-plans/$plan_id/generations" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "剪辑计划关联生成历史" || fail "剪辑计划关联生成历史" "HTTP $code"
|
||||
fi
|
||||
|
||||
# 时间线
|
||||
if [ -n "$plan_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/edit-plans/$plan_id/timeline" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "剪辑计划时间线" || fail "剪辑计划时间线" "HTTP $code"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===== 模块:剪辑模板 =====
|
||||
test_templates() {
|
||||
should_run "templates" || return 0
|
||||
[ -z "$TOKEN" ] && return 0
|
||||
|
||||
section "3. 剪辑模板 (templates)"
|
||||
|
||||
local tpl_id=""
|
||||
|
||||
# 列表
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/templates" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "模板列表" || fail "模板列表" "HTTP $code"
|
||||
|
||||
# 分类
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/templates/categories/list" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "模板分类列表" || fail "模板分类列表" "HTTP $code"
|
||||
|
||||
# 创建
|
||||
local resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E测试模板-完整","mode":"voice_over","description":"测试模板","category":"vlog","config":{"cover":{"type":"ai"},"title":{"ai_enabled":true},"subtitle":{"enabled":true},"bgm":{"enabled":true}}}' \
|
||||
"$BASE_URL/api/v1/templates" --max-time $CURL_TIMEOUT)
|
||||
code=$(echo "$resp" | tail -1)
|
||||
local body=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$code" = "200" ] || [ "$code" = "201" ]; then
|
||||
pass "创建模板"
|
||||
tpl_id=$(json_get "$body" "id")
|
||||
[ -z "$tpl_id" ] && tpl_id=$(json_get "$body" "data.id")
|
||||
CREATED_TEMPLATES+=("$tpl_id")
|
||||
else
|
||||
fail "创建模板" "HTTP $code, body: ${body:0:200}"
|
||||
fi
|
||||
|
||||
# 详情
|
||||
if [ -n "$tpl_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/templates/$tpl_id" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "模板详情" || fail "模板详情" "HTTP $code"
|
||||
fi
|
||||
|
||||
# toggle-favorite
|
||||
if [ -n "$tpl_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/templates/$tpl_id/toggle-favorite" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "模板收藏切换 (toggle-favorite)" || fail "模板收藏切换" "HTTP $code"
|
||||
fi
|
||||
|
||||
# validate
|
||||
if [ -n "$tpl_id" ]; then
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d '{}' \
|
||||
"$BASE_URL/api/v1/templates/$tpl_id/validate" --max-time $CURL_TIMEOUT)
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
pass "模板配置验证 (validate)"
|
||||
else
|
||||
fail "模板配置验证" "HTTP $code"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ===== 模块:素材库 =====
|
||||
test_assets() {
|
||||
should_run "assets" || return 0
|
||||
[ -z "$TOKEN" ] && return 0
|
||||
|
||||
section "4. 素材库 (assets & asset-libraries)"
|
||||
|
||||
# 素材列表
|
||||
local resp=$(curl -s -w "\n%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/assets" --max-time $CURL_TIMEOUT)
|
||||
local code=$(echo "$resp" | tail -1)
|
||||
local body=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$code" = "200" ]; then
|
||||
pass "素材列表"
|
||||
local count=$(echo "$body" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
items = d.get('items', []) or d.get('data', []) or []
|
||||
print(len(items))
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ "${count:-0}" -gt 0 ]; then
|
||||
local has_file=$(echo "$body" | grep -c '"file_url"' || true)
|
||||
local has_thumb=$(echo "$body" | grep -c '"thumbnail_url"' || true)
|
||||
[ "$has_file" -gt 0 ] && pass "素材包含 file_url 字段" || fail "素材 file_url 字段" "未找到"
|
||||
[ "$has_thumb" -gt 0 ] && pass "素材包含 thumbnail_url 字段" || fail "素材 thumbnail_url 字段" "未找到"
|
||||
else
|
||||
pass "新用户素材列表为空(正常)"
|
||||
pass "跳过 file_url 检查(无素材)"
|
||||
pass "跳过 thumbnail_url 检查(无素材)"
|
||||
fi
|
||||
else
|
||||
fail "素材列表" "HTTP $code"
|
||||
fi
|
||||
|
||||
# 素材库列表
|
||||
resp=$(curl -s -w "\n%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/asset-libraries" --max-time $CURL_TIMEOUT)
|
||||
code=$(echo "$resp" | tail -1)
|
||||
body=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$code" = "200" ]; then
|
||||
pass "素材库列表 (asset-libraries)"
|
||||
else
|
||||
fail "素材库列表" "HTTP $code"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===== 模块:诊断 =====
|
||||
test_diagnosis() {
|
||||
should_run "diagnosis" || return 0
|
||||
[ -z "$TOKEN" ] && return 0
|
||||
|
||||
section "5. 素材诊断"
|
||||
|
||||
local project_id=""
|
||||
|
||||
# 创建项目
|
||||
local resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E诊断测试项目"}' \
|
||||
"$BASE_URL/api/v1/projects" --max-time $CURL_TIMEOUT)
|
||||
local pcode=$(echo "$resp" | tail -1)
|
||||
local pbody=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$pcode" = "200" ] || [ "$pcode" = "201" ]; then
|
||||
project_id=$(json_get "$pbody" "id")
|
||||
[ -z "$project_id" ] && project_id=$(json_get "$pbody" "data.id")
|
||||
pass "创建测试项目"
|
||||
CREATED_PROJECTS+=("$project_id")
|
||||
else
|
||||
# 尝试获取已有项目
|
||||
resp=$(curl -s -w "\n%{http_code}" -H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/projects" --max-time $CURL_TIMEOUT)
|
||||
local lcode=$(echo "$resp" | tail -1)
|
||||
local lbody=$(echo "$resp" | sed '$d')
|
||||
if [ "$lcode" = "200" ]; then
|
||||
project_id=$(echo "$lbody" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
items = d.get('items', []) or d.get('data', []) or []
|
||||
print(items[0].get('id', '') if items else '')
|
||||
" 2>/dev/null)
|
||||
pass "获取已有项目"
|
||||
else
|
||||
fail "获取项目" "HTTP $lcode"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 项目级素材诊断
|
||||
if [ -n "$project_id" ]; then
|
||||
resp=$(curl -s -w "\n%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/projects/$project_id/asset-diagnosis" --max-time $CURL_TIMEOUT)
|
||||
local code=$(echo "$resp" | tail -1)
|
||||
body=$(echo "$resp" | sed '$d')
|
||||
|
||||
if [ "$code" = "200" ]; then
|
||||
pass "项目级素材诊断"
|
||||
|
||||
# 检查新格式
|
||||
local has_score=$(echo "$body" | grep -c '"readiness_score"' || true)
|
||||
local has_label=$(echo "$body" | grep -c '"readiness_label"' || true)
|
||||
local has_total=$(echo "$body" | grep -c '"total_assets"' || true)
|
||||
|
||||
if [ "$has_score" -gt 0 ] || [ "$has_label" -gt 0 ] || [ "$has_total" -gt 0 ]; then
|
||||
pass "诊断返回新格式(readiness_score/readiness_label/total_assets)"
|
||||
else
|
||||
local keys=$(echo "$body" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
data = d.get('data', d)
|
||||
if isinstance(data, dict):
|
||||
print(','.join(list(data.keys())[:8]))
|
||||
else:
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
fail "诊断返回结构" "keys: $keys"
|
||||
fi
|
||||
else
|
||||
fail "项目级素材诊断" "HTTP $code"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ===== 模块:生成任务 =====
|
||||
test_generation() {
|
||||
should_run "generation" || return 0
|
||||
[ -z "$TOKEN" ] && return 0
|
||||
|
||||
section "6. 生成任务"
|
||||
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/generation/tasks" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "生成任务列表" || fail "生成任务列表" "HTTP $code"
|
||||
}
|
||||
|
||||
# ===== 模块:订阅 =====
|
||||
test_subscription() {
|
||||
should_run "subscription" || return 0
|
||||
[ -z "$TOKEN" ] && return 0
|
||||
|
||||
section "7. 订阅与配额"
|
||||
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/subscription/current" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "当前订阅信息" || fail "当前订阅信息" "HTTP $code"
|
||||
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/subscription/billing-records" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "订阅账单记录" || fail "订阅账单记录" "HTTP $code"
|
||||
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/dashboard/overview" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "仪表盘概览" || fail "仪表盘概览" "HTTP $code"
|
||||
}
|
||||
|
||||
# ===== 模块:其他核心接口 =====
|
||||
test_misc() {
|
||||
should_run "misc" || return 0
|
||||
[ -z "$TOKEN" ] && return 0
|
||||
|
||||
section "8. 其他核心接口"
|
||||
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "$AUTH_HEADER" "$BASE_URL/api/v1/voices" --max-time $CURL_TIMEOUT)
|
||||
[ "$code" = "200" ] && pass "音色列表" || fail "音色列表" "HTTP $code"
|
||||
|
||||
# 标题生成
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
-d '{"name":"E2E测试标题","text":"今天天气真好我们出去玩"}' \
|
||||
"$BASE_URL/api/v1/titles" --max-time $CURL_TIMEOUT)
|
||||
if [ "$code" = "200" ] || [ "$code" = "201" ]; then
|
||||
pass "AI 标题生成"
|
||||
elif [ "$code" = "500" ] || [ "$code" = "503" ]; then
|
||||
pass "AI 标题生成(HTTP $code,环境限制,接口存在)"
|
||||
else
|
||||
fail "AI 标题生成" "HTTP $code"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===== 模块:Nginx 路由 =====
|
||||
test_nginx() {
|
||||
should_run "nginx" || return 0
|
||||
|
||||
section "9. 前端 SPA 路由验证(Nginx)"
|
||||
|
||||
# 需要 WEB_URL 环境变量
|
||||
local web_url="${WEB_URL:-}"
|
||||
if [ -z "$web_url" ]; then
|
||||
info "跳过(WEB_URL 未设置)"
|
||||
return 0
|
||||
fi
|
||||
web_url="${web_url%/}"
|
||||
|
||||
check_spa_route() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$web_url$path")
|
||||
local content_type=$(curl -s -I --max-time 10 "$web_url$path" 2>/dev/null | grep -i "content-type" | tr -d '\r')
|
||||
|
||||
if [ "$code" = "200" ] && echo "$content_type" | grep -q "text/html"; then
|
||||
pass "$name(200 + text/html)"
|
||||
elif [ "$code" = "200" ]; then
|
||||
fail "$name" "HTTP 200 但 content-type 不是 text/html"
|
||||
else
|
||||
fail "$name" "HTTP $code"
|
||||
fi
|
||||
}
|
||||
|
||||
check_spa_route "/" "首页"
|
||||
check_spa_route "/app/dashboard" "/app/dashboard"
|
||||
check_spa_route "/app/editing-planner" "/app/editing-planner"
|
||||
check_spa_route "/app/assets" "/app/assets"
|
||||
check_spa_route "/app/generate" "/app/generate"
|
||||
check_spa_route "/app/templates" "/app/templates"
|
||||
check_spa_route "/app/settings/profile" "深层路由 /app/settings/profile"
|
||||
|
||||
# /assets 特殊检查:不能返回 403(Nginx 目录列表问题)
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$web_url/assets")
|
||||
if [ "$code" = "403" ]; then
|
||||
fail "/assets 路由" "返回 403(Nginx 目录列表问题)"
|
||||
else
|
||||
pass "/assets 路由正常(非 403)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===== 清理 =====
|
||||
cleanup() {
|
||||
[ "$CLEANUP_ENABLED" != "1" ] && return 0
|
||||
[ -z "$TOKEN" ] && return 0
|
||||
|
||||
section "清理测试数据"
|
||||
|
||||
for plan_id in "${CREATED_PLANS[@]}"; do
|
||||
[ -z "$plan_id" ] && continue
|
||||
curl -s -o /dev/null -X DELETE -H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/edit-plans/$plan_id" --max-time 10 || true
|
||||
done
|
||||
[ ${#CREATED_PLANS[@]} -gt 0 ] && info "清理了 ${#CREATED_PLANS[@]} 个剪辑计划"
|
||||
|
||||
for tpl_id in "${CREATED_TEMPLATES[@]}"; do
|
||||
[ -z "$tpl_id" ] && continue
|
||||
curl -s -o /dev/null -X DELETE -H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/templates/$tpl_id" --max-time 10 || true
|
||||
done
|
||||
[ ${#CREATED_TEMPLATES[@]} -gt 0 ] && info "清理了 ${#CREATED_TEMPLATES[@]} 个模板"
|
||||
|
||||
for proj_id in "${CREATED_PROJECTS[@]}"; do
|
||||
[ -z "$proj_id" ] && continue
|
||||
curl -s -o /dev/null -X DELETE -H "$AUTH_HEADER" \
|
||||
"$BASE_URL/api/v1/projects/$proj_id" --max-time 10 || true
|
||||
done
|
||||
[ ${#CREATED_PROJECTS[@]} -gt 0 ] && info "清理了 ${#CREATED_PROJECTS[@]} 个项目"
|
||||
|
||||
echo "✅ 清理完成"
|
||||
}
|
||||
|
||||
# ===== 主流程 =====
|
||||
main() {
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════╗"
|
||||
echo "║ API E2E 冒烟测试 ║"
|
||||
echo "╚══════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "环境: $BASE_URL"
|
||||
echo "模块: $MODULES"
|
||||
echo "清理: $CLEANUP_ENABLED"
|
||||
echo "开始时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
# 健康检查(不需要鉴权)
|
||||
test_health
|
||||
|
||||
# 鉴权
|
||||
if ! setup_auth; then
|
||||
echo ""
|
||||
echo "❌ 认证失败,终止测试"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 业务模块
|
||||
test_edit_plans
|
||||
test_templates
|
||||
test_assets
|
||||
test_diagnosis
|
||||
test_generation
|
||||
test_subscription
|
||||
test_misc
|
||||
test_nginx
|
||||
|
||||
# 清理
|
||||
cleanup
|
||||
|
||||
# 总结
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo "测试完成: $PASSED 通过, $FAILED 失败"
|
||||
echo "============================================================"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "失败用例:"
|
||||
echo -e "$FAIL_LIST"
|
||||
echo ""
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo "🎉 全部通过!"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user