/** * API 级 e2e 共享助手 * ================================================================ * 背景:远程环境 http://cfc.iwintrue.com 仅部署后端 API(Spring Boot), * 未托管前端 SPA(访问任意路径均返回 Spring Boot 白标 404), * 因此基于 DOM 的 e2e(page.goto + 选择器等待)在远程不可运行。 * 本助手为「API 级 e2e」提供统一入口: * - registerRole:测试专用直注接口 /api/auth/direct-register 自注册, * 不依赖种子数据库、不依赖前端表单登录、不打印任何敏感信息; * - apiPost:统一 POST 封装(项目约定接口一律使用 POST)。 * * 安全约定:本文件及调用方一律不打印 token / 手机号等敏感信息。 */ const { expect } = require('@playwright/test'); /** 生成合法手机号(严格 11 位,匹配后端 ^1[3-9]\d{9}$ 校验,否则返回「手机号格式不正确」) */ function randPhone() { return '13' + String(Math.floor(Math.random() * 900000000) + 100000000); } /** * 自注册账号并返回会话上下文 * @param {import('@playwright/test').APIRequestContext} request * @param {string} role 角色:parent / teacher / admin ... * @returns {Promise<{token:string,userId:*,familyId:*,role:string,roles:*}>} */ async function registerRole(request, role, nickname) { const res = await request.post('/api/auth/direct-register', { data: { phone: randPhone(), role, nickname: nickname || 'QA-' + role }, timeout: API_TIMEOUT_MS, }); const body = await res.json().catch(() => ({})); expect(body.code, 'direct-register 应当成功(code=200)').toBe(200); const d = body.data || {}; expect(typeof d.token, '响应应包含 token').toBe('string'); return { token: d.token, userId: d.userId, familyId: d.familyId, role: d.role, roles: d.roles, }; } /** * 统一 POST 调用,返回 { status, code, message, data } * 若响应非 JSON(如未捕获异常导致的 Spring 500),回退以 HTTP 状态码作为 code, * 避免断言拿到 undefined 造成误判。 * * 超时说明:远程 /api/family/member/add 冷启动首调实测约 16s(后续约 1.5s), * 超过 Playwright 默认 actionTimeout(15s) 会误报超时,故显式放宽到 60s。 */ const API_TIMEOUT_MS = 120000; // 冷启动容忍:远端 member/add 冷启动实测 16s+,降级窗口可能超过 60s async function apiPost(request, path, body, token) { const headers = {}; if (token) headers.Authorization = 'Bearer ' + token; const res = await request.post(path, { data: body || {}, headers, timeout: API_TIMEOUT_MS }); const raw = await res.text(); let json = null; try { json = raw ? JSON.parse(raw) : null; } catch (e) { json = null; } if (!json || typeof json !== 'object') { return { status: res.status(), code: res.status(), message: 'NON_JSON_RESPONSE', data: null, }; } return { status: res.status(), code: json.code, message: json.message, data: json.data, }; } /** 从成员列表中找出对应登录用户的成员记录(用于「切换到自己」等用例) */ function pickMemberByUserId(members, userId) { if (!Array.isArray(members)) return null; return members.find((m) => String(m.userId) === String(userId)) || null; } /** 统一时间戳后缀,避免同名字段唯一约束冲突 */ function uniqueSuffix() { return String(Date.now()).slice(-8); } module.exports = { randPhone, registerRole, apiPost, pickMemberByUserId, uniqueSuffix };