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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
/**
|
||
* 个人设置页面 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 } = 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 })
|
||
})
|
||
})
|