Files
xiaoxia-saas/apps/web/e2e/subscription-full.spec.ts
xiaoxia 0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
ci: Prettier纳入两层防御体系 (#520)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:08:19 +08:00

565 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 订阅完整流程 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()
})
})