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
617 lines
18 KiB
TypeScript
617 lines
18 KiB
TypeScript
/**
|
||
* 订阅完整流程 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();
|
||
});
|
||
});
|