78d1c88ca1
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
265 lines
7.9 KiB
TypeScript
Executable File
265 lines
7.9 KiB
TypeScript
Executable File
/**
|
|
* 订阅管理 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 限流自动等待重试 */
|
|
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 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,
|
|
};
|
|
}
|
|
|
|
test.describe("Subscription route guard", () => {
|
|
test.describe.configure({ timeout: 120_000 });
|
|
test("redirects anonymous users to login", async ({ page }) => {
|
|
await page.goto("/app/subscription");
|
|
await expect(page).toHaveURL(/\/login/);
|
|
});
|
|
});
|
|
|
|
test.describe("订阅信息查看", () => {
|
|
test.describe.configure({ timeout: 120_000 });
|
|
test("获取当前订阅信息 - 正向", async ({ request }) => {
|
|
const { headers } = await createAuthedUser(request, "sub-info");
|
|
|
|
const response = await request.get(`${apiBase}/subscription/current`, {
|
|
headers,
|
|
});
|
|
|
|
expect(
|
|
response.ok(),
|
|
`获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
|
).toBeTruthy();
|
|
|
|
const data = await response.json();
|
|
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
|
|
expect(data.status, "应返回 status").toBeTruthy();
|
|
});
|
|
|
|
test("未登录获取订阅信息 - 反向", async ({ request }) => {
|
|
const response = await request.get(`${apiBase}/subscription/current`);
|
|
expect([401, 403]).toContain(response.status());
|
|
});
|
|
});
|
|
|
|
test.describe("订阅降级", () => {
|
|
test.describe.configure({ timeout: 120_000 });
|
|
test("Pro 用户降级到 Standard - 正向", async ({ request }) => {
|
|
const { headers } = await createAuthedUser(request, "sub-downgrade");
|
|
|
|
// 先升级到 Pro
|
|
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
|
headers,
|
|
data: {
|
|
target_plan_id: "pro",
|
|
billing_cycle: "monthly",
|
|
},
|
|
});
|
|
expect(
|
|
upgrade.ok(),
|
|
`升级到 Pro 应成功: ${await upgrade.text()}`,
|
|
).toBeTruthy();
|
|
|
|
// 降级到 Standard
|
|
const downgrade = await request.post(
|
|
`${apiBase}/subscription/change-plan`,
|
|
{
|
|
headers,
|
|
data: {
|
|
target_plan_id: "standard",
|
|
billing_cycle: "monthly",
|
|
},
|
|
},
|
|
);
|
|
|
|
// 降级应成功或返回提示信息(某些业务可能限制降级)
|
|
expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500);
|
|
|
|
const data = await downgrade.json();
|
|
// 成功或失败都应有明确响应
|
|
expect(data).toBeTruthy();
|
|
});
|
|
|
|
test("降级到相同套餐 - 反向", async ({ request }) => {
|
|
const { headers } = await createAuthedUser(request, "sub-same");
|
|
|
|
// 用户默认为 free,再次选择 free
|
|
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
|
headers,
|
|
data: {
|
|
target_plan_id: "free",
|
|
billing_cycle: "monthly",
|
|
},
|
|
});
|
|
|
|
// 相同套餐应返回 200 + success=false,或者 400
|
|
if (response.ok()) {
|
|
const data = await response.json();
|
|
expect(data.success).toBe(false);
|
|
} else {
|
|
expect([400, 422]).toContain(response.status());
|
|
}
|
|
});
|
|
|
|
test("降级到无效套餐 - 反向", async ({ request }) => {
|
|
const { headers } = await createAuthedUser(request, "sub-badplan");
|
|
|
|
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("取消订阅 - 反向(免费用户)", async ({ request }) => {
|
|
const { headers } = await createAuthedUser(request, "sub-cancel");
|
|
|
|
// 免费用户取消订阅应返回错误
|
|
const response = await request.post(`${apiBase}/subscription/cancel`, {
|
|
headers,
|
|
});
|
|
|
|
// 免费用户可能不需要取消,返回 400 或类似错误
|
|
if (!response.ok()) {
|
|
const data = await response.json();
|
|
expect(
|
|
data.error?.message || data.detail || data.message,
|
|
"应返回错误信息",
|
|
).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test("未登录取消订阅 - 反向", async ({ request }) => {
|
|
const response = await request.post(`${apiBase}/subscription/cancel`);
|
|
expect([401, 403]).toContain(response.status());
|
|
});
|
|
|
|
test("切换自动续费 - 正向", async ({ request }) => {
|
|
const { headers } = await createAuthedUser(request, "sub-autorenew");
|
|
|
|
// 关闭自动续费
|
|
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-autoren-bad");
|
|
|
|
// 缺少 enabled 字段
|
|
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("获取账单记录 - 正向", async ({ request }) => {
|
|
const { headers } = await createAuthedUser(request, "sub-bills");
|
|
|
|
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("未登录获取账单记录 - 反向", async ({ request }) => {
|
|
const response = await request.get(
|
|
`${apiBase}/subscription/billing-records`,
|
|
);
|
|
expect([401, 403]).toContain(response.status());
|
|
});
|
|
});
|