627 lines
27 KiB
JavaScript
627 lines
27 KiB
JavaScript
/**
|
||
* 全面端到端测试脚本
|
||
*
|
||
* 流程:
|
||
* 1. 后台管理系统:admin 创建团课(无封面、张教练)→ 保存二维码
|
||
* 2. 会员端:预约团课(开课前30分钟以上)
|
||
* 3. 调整时间 → 扫码签到(开课前2小时内)
|
||
* 4. 调整时间 → 教练端:手动开课 → 手动结课
|
||
*
|
||
* API Base: http://192.168.110.64:8084
|
||
* 认证: HMAC-SHA256 + JWT
|
||
*
|
||
* 业务规则(来自后端代码):
|
||
* - 预约: 距开课 >= 30分钟
|
||
* - 签到: 开课前2小时 ~ 课程结束
|
||
* - 开课(>=60min课程): 开课时间后10分钟内正常, 10-30分钟迟到, >30分钟拒绝
|
||
* - 结课: 结束时间后10分钟内
|
||
*/
|
||
|
||
const http = require('http');
|
||
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const API_BASE = 'http://192.168.110.64:8084';
|
||
const SIGNATURE_SECRET = 'NovalonManageSystemSecretKey2026';
|
||
const QRCODE_DIR = path.resolve(__dirname, 'QRCODE');
|
||
|
||
// 测试账号
|
||
const ADMIN = { username: 'admin', password: 'Test@123' };
|
||
const MEMBER_CODE = 'dev-test-fullflow-' + Date.now();
|
||
const COACH = { username: 'coach_zhang', password: 'Test@123' };
|
||
|
||
// 结果记录
|
||
const results = {
|
||
steps: [],
|
||
startTime: new Date().toISOString(),
|
||
endTime: null,
|
||
courseId: null,
|
||
courseName: null,
|
||
memberId: null,
|
||
bookingId: null,
|
||
qrPath: null,
|
||
passed: 0,
|
||
failed: 0
|
||
};
|
||
|
||
function record(step, status, detail) {
|
||
const entry = { step, status, detail, time: new Date().toISOString() };
|
||
results.steps.push(entry);
|
||
const icon = status === 'PASS' ? '✓' : status === 'SKIP' ? '○' : '✗';
|
||
console.log(` ${icon} [${status}] ${step}${detail ? ': ' + detail : ''}`);
|
||
if (status === 'PASS') results.passed++;
|
||
else if (status === 'FAIL') results.failed++;
|
||
}
|
||
|
||
// ── 本地时间格式化(发送给服务端 LocalDateTime,不带时区)──
|
||
function formatLocalTime(d) {
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
|
||
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||
}
|
||
|
||
// ── HTTP 请求工具 ──
|
||
function apiRequest(method, urlPath, body, token) {
|
||
return new Promise((resolve, reject) => {
|
||
const u = new URL(API_BASE + urlPath);
|
||
const timestamp = Date.now();
|
||
const nonce = timestamp.toString(36) + '-' + Math.random().toString(36).substring(2, 8);
|
||
const bodyStr = body ? JSON.stringify(body) : '';
|
||
const stringToSign = [method.toUpperCase(), u.pathname + u.search, '', bodyStr, String(timestamp), nonce].join('\n');
|
||
const sig = crypto.createHmac('sha256', SIGNATURE_SECRET).update(stringToSign).digest('base64');
|
||
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'X-Signature': sig,
|
||
'X-Timestamp': String(timestamp),
|
||
'X-Nonce': nonce
|
||
};
|
||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||
|
||
const req = http.request({
|
||
hostname: u.hostname, port: u.port, path: u.pathname + u.search,
|
||
method, headers, timeout: 15000
|
||
}, (res) => {
|
||
let data = '';
|
||
res.on('data', c => data += c);
|
||
res.on('end', () => {
|
||
let parsed;
|
||
try { parsed = JSON.parse(data); } catch (e) { parsed = data; }
|
||
resolve({ status: res.statusCode, data: parsed });
|
||
});
|
||
});
|
||
req.on('error', e => reject(e));
|
||
if (bodyStr) req.write(bodyStr);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// 下载文件到本地
|
||
function downloadFile(urlPath, token, destPath) {
|
||
return new Promise((resolve, reject) => {
|
||
const u = new URL(API_BASE + urlPath);
|
||
const timestamp = Date.now();
|
||
const nonce = timestamp.toString(36) + '-' + Math.random().toString(36).substring(2, 8);
|
||
const stringToSign = ['GET', u.pathname + u.search, '', '', String(timestamp), nonce].join('\n');
|
||
const sig = crypto.createHmac('sha256', SIGNATURE_SECRET).update(stringToSign).digest('base64');
|
||
|
||
const headers = {
|
||
'X-Signature': sig, 'X-Timestamp': String(timestamp), 'X-Nonce': nonce
|
||
};
|
||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||
|
||
const req = http.request({
|
||
hostname: u.hostname, port: u.port, path: u.pathname + u.search,
|
||
method: 'GET', headers, timeout: 15000
|
||
}, (res) => {
|
||
const chunks = [];
|
||
res.on('data', c => chunks.push(c));
|
||
res.on('end', () => {
|
||
const buf = Buffer.concat(chunks);
|
||
fs.writeFileSync(destPath, buf);
|
||
resolve(destPath);
|
||
});
|
||
});
|
||
req.on('error', e => reject(e));
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(r => setTimeout(r, ms));
|
||
}
|
||
|
||
// ── 主流程 ──
|
||
async function main() {
|
||
console.log('═══════════════════════════════════════════');
|
||
console.log(' 全面端到端测试 - 全流程');
|
||
console.log(' 包含: 后台管理 / 会员端 / 教练端');
|
||
console.log(` 时间: ${results.startTime}`);
|
||
console.log(` 本地时间: ${formatLocalTime(new Date())}`);
|
||
console.log('═══════════════════════════════════════════\n');
|
||
|
||
// 确保 QRCODE 目录存在
|
||
if (!fs.existsSync(QRCODE_DIR)) fs.mkdirSync(QRCODE_DIR, { recursive: true });
|
||
|
||
let adminToken, coachToken, memberToken;
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 阶段1:后台管理系统
|
||
// ═══════════════════════════════════════════
|
||
console.log('── 阶段1:后台管理系统 ──');
|
||
|
||
// 1a. 管理员登录
|
||
try {
|
||
const loginRes = await apiRequest('POST', '/api/auth/login', ADMIN);
|
||
if (loginRes.status === 200 && loginRes.data.token) {
|
||
adminToken = loginRes.data.token;
|
||
record('1a. 管理员登录', 'PASS', `${ADMIN.username} / userId=${loginRes.data.userId}`);
|
||
} else {
|
||
record('1a. 管理员登录', 'FAIL', `status=${loginRes.status}, body=${JSON.stringify(loginRes.data).substring(0, 200)}`);
|
||
throw new Error('Admin login failed, cannot continue');
|
||
}
|
||
} catch (e) {
|
||
record('1a. 管理员登录', 'FAIL', e.message);
|
||
if (!adminToken) { generateMarkdownReport(); return; }
|
||
}
|
||
|
||
// 1b. 获取教练列表
|
||
let coachId = null;
|
||
if (adminToken) {
|
||
try {
|
||
const coachRes = await apiRequest('GET', '/api/coach/list', null, adminToken);
|
||
if (coachRes.status === 200) {
|
||
const coaches = Array.isArray(coachRes.data) ? coachRes.data : (coachRes.data?.data || []);
|
||
const zhang = coaches.find(c => c.username === 'coach_zhang' || (c.nickname && c.nickname.includes('张')));
|
||
if (zhang) {
|
||
coachId = zhang.id;
|
||
record('1b. 获取教练列表', 'PASS', `找到 ${COACH.username}, id=${coachId}, nickname=${zhang.nickname || '-'}`);
|
||
} else {
|
||
record('1b. 获取教练列表', 'FAIL', `未找到 ${COACH.username}, 列表: ${coaches.map(c => c.username || c.nickname).join(', ')}`);
|
||
}
|
||
} else {
|
||
record('1b. 获取教练列表', 'FAIL', `status=${coachRes.status}`);
|
||
}
|
||
} catch (e) {
|
||
record('1b. 获取教练列表', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// 1c. 创建团课(无封面、张教练负责)
|
||
let courseId = null;
|
||
const courseName = '全流程测试-' + Date.now().toString(36);
|
||
// 初始时间:5小时后开课,留足预约时间(>=30分钟)
|
||
const createStartTime = new Date(Date.now() + 5 * 60 * 60 * 1000);
|
||
const createEndTime = new Date(createStartTime.getTime() + 60 * 60 * 1000);
|
||
|
||
if (adminToken && coachId) {
|
||
try {
|
||
const createBody = {
|
||
courseName: courseName,
|
||
coachId: parseInt(coachId),
|
||
courseType: 1,
|
||
startTime: formatLocalTime(createStartTime),
|
||
endTime: formatLocalTime(createEndTime),
|
||
maxMembers: 20,
|
||
location: '测试场地-全流程',
|
||
coverImage: '', // 无封面
|
||
description: '自动化全流程端到端测试团课'
|
||
};
|
||
console.log(' 创建请求:', JSON.stringify(createBody));
|
||
|
||
const createRes = await apiRequest('POST', '/api/groupCourse', createBody, adminToken);
|
||
console.log(' 创建响应: status=' + createRes.status + ', body=' + JSON.stringify(createRes.data).substring(0, 300));
|
||
|
||
const createdCourse = createRes.data?.data || createRes.data;
|
||
if (createRes.status === 200 && createdCourse?.id) {
|
||
courseId = createdCourse.id;
|
||
results.courseId = courseId;
|
||
results.courseName = courseName;
|
||
record('1c. 创建团课', 'PASS',
|
||
`id=${courseId}, name="${courseName}", coachId=${coachId}, 无封面, startTime=${formatLocalTime(createStartTime)}`);
|
||
} else {
|
||
// 尝试从message中提取信息
|
||
const msg = createRes.data?.message || '';
|
||
record('1c. 创建团课', 'FAIL',
|
||
`status=${createRes.status}, msg=${msg}, body=${JSON.stringify(createRes.data).substring(0, 300)}`);
|
||
}
|
||
} catch (e) {
|
||
record('1c. 创建团课', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// 1d. 获取并保存二维码
|
||
if (adminToken && courseId) {
|
||
try {
|
||
const detailRes = await apiRequest('GET', `/api/groupCourse/${courseId}/detail`, null, adminToken);
|
||
const course = detailRes.data;
|
||
|
||
if (course.qrCodePath) {
|
||
const destFile = path.join(QRCODE_DIR, courseName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_') + '.png');
|
||
try {
|
||
await downloadFile(course.qrCodePath, adminToken, destFile);
|
||
results.qrPath = destFile;
|
||
const size = fs.statSync(destFile).size;
|
||
record('1d. 保存二维码', 'PASS', `已保存: ${destFile} (${size} bytes)`);
|
||
} catch (e) {
|
||
record('1d. 保存二维码', 'FAIL', `下载失败: ${e.message}`);
|
||
}
|
||
} else {
|
||
record('1d. 保存二维码', 'FAIL', 'qrCodePath 为空(可能异步生成中)');
|
||
}
|
||
} catch (e) {
|
||
record('1d. 保存二维码', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 阶段2:会员端 - 预约 + 签到
|
||
// ═══════════════════════════════════════════
|
||
console.log('\n── 阶段2:会员端 ──');
|
||
|
||
// 2a. 会员登录
|
||
if (courseId) {
|
||
try {
|
||
const memberLoginRes = await apiRequest('POST', '/api/member/auth/miniapp/login', { code: MEMBER_CODE });
|
||
if (memberLoginRes.status === 200 && memberLoginRes.data.accessToken) {
|
||
memberToken = memberLoginRes.data.accessToken;
|
||
results.memberId = memberLoginRes.data.memberId;
|
||
record('2a. 会员登录', 'PASS', `memberId=${results.memberId}`);
|
||
} else {
|
||
record('2a. 会员登录', 'FAIL', `status=${memberLoginRes.status}, ${JSON.stringify(memberLoginRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('2a. 会员登录', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// 2b. 预约团课(现在距开课5小时 > 30分钟,满足预约条件)
|
||
if (memberToken && courseId) {
|
||
try {
|
||
const bookBody = { courseId: parseInt(courseId), memberId: parseInt(results.memberId) };
|
||
console.log(' 预约请求:', JSON.stringify(bookBody));
|
||
const bookRes = await apiRequest('POST', '/api/groupCourse/book', bookBody, memberToken);
|
||
console.log(' 预约响应: status=' + bookRes.status + ', body=' + JSON.stringify(bookRes.data).substring(0, 300));
|
||
|
||
if (bookRes.status === 200) {
|
||
results.bookingId = bookRes.data?.data?.id || bookRes.data?.id;
|
||
record('2b. 预约团课', 'PASS',
|
||
`courseId=${courseId}, bookingId=${results.bookingId}, 距开课约5h(>=30min要求)`);
|
||
} else if (bookRes.status === 400) {
|
||
const msg = bookRes.data?.message || '';
|
||
if (msg.includes('已预约')) {
|
||
record('2b. 预约团课', 'SKIP', '已预约过该课程(可能是重复测试)');
|
||
} else {
|
||
record('2b. 预约团课', 'FAIL', `status=400, msg=${msg}`);
|
||
}
|
||
} else {
|
||
record('2b. 预约团课', 'FAIL', `status=${bookRes.status}, ${JSON.stringify(bookRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('2b. 预约团课', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// 2c. 调整团课时间:签到需要 startTime 在 2 小时内(规则: now >= startTime - 2h)
|
||
const signInStart = new Date(Date.now() + 60 * 60 * 1000); // 1小时后开课(在2h窗口内)
|
||
if (adminToken && courseId) {
|
||
try {
|
||
const updateBody = {
|
||
startTime: formatLocalTime(signInStart),
|
||
endTime: formatLocalTime(new Date(signInStart.getTime() + 60 * 60 * 1000))
|
||
};
|
||
const updateRes = await apiRequest('PUT', `/api/groupCourse/${courseId}`, updateBody, adminToken);
|
||
console.log(' 调整时间响应: status=' + updateRes.status);
|
||
|
||
if (updateRes.status === 200) {
|
||
record('2c. 调整课程时间(签到用)', 'PASS',
|
||
`startTime→${formatLocalTime(signInStart)}(1h后→满足签到2h窗口)`);
|
||
} else {
|
||
record('2c. 调整课程时间(签到用)', 'FAIL',
|
||
`status=${updateRes.status}, ${JSON.stringify(updateRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('2c. 调整课程时间(签到用)', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// 2d. 签到(扫码签到 = 调用 groupCourse/signin API)
|
||
if (memberToken && courseId) {
|
||
try {
|
||
const signinBody = { courseId: parseInt(courseId) };
|
||
const signinRes = await apiRequest('POST', `/api/groupCourse/signin/${results.memberId}`, signinBody, memberToken);
|
||
console.log(' 签到响应: status=' + signinRes.status + ', body=' + JSON.stringify(signinRes.data).substring(0, 300));
|
||
|
||
if (signinRes.status === 200) {
|
||
record('2d. 扫码签到', 'PASS',
|
||
`courseId=${courseId}, memberId=${results.memberId}, 签到时间距开课约1h(满足2h窗口)`);
|
||
} else if (signinRes.status === 400) {
|
||
const msg = signinRes.data?.message || '';
|
||
if (msg.includes('已出席') || msg.includes('已签到')) {
|
||
record('2d. 扫码签到', 'SKIP', '已签到过');
|
||
} else {
|
||
record('2d. 扫码签到', 'FAIL', `status=400, msg=${msg}`);
|
||
}
|
||
} else {
|
||
record('2d. 扫码签到', 'FAIL',
|
||
`status=${signinRes.status}, ${JSON.stringify(signinRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('2d. 扫码签到', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 阶段3:教练端 - 开课 + 结课
|
||
// ═══════════════════════════════════════════
|
||
console.log('\n── 阶段3:教练端 ──');
|
||
|
||
// 3a. 教练登录
|
||
try {
|
||
const coachLoginRes = await apiRequest('POST', '/api/auth/login', COACH);
|
||
if (coachLoginRes.status === 200 && coachLoginRes.data.token) {
|
||
coachToken = coachLoginRes.data.token;
|
||
record('3a. 教练登录', 'PASS', `${COACH.username}, userId=${coachLoginRes.data.userId}`);
|
||
} else {
|
||
record('3a. 教练登录', 'FAIL', `status=${coachLoginRes.status}, ${JSON.stringify(coachLoginRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('3a. 教练登录', 'FAIL', e.message);
|
||
}
|
||
|
||
// 3b. 调整课程时间: 开课需要已过 startTime(规则: 10分钟内正常开课)
|
||
const coachStartTime = new Date(Date.now() - 3 * 60 * 1000); // 3分钟前
|
||
if (adminToken && courseId) {
|
||
try {
|
||
const updateBody = {
|
||
startTime: formatLocalTime(coachStartTime),
|
||
endTime: formatLocalTime(new Date(coachStartTime.getTime() + 60 * 60 * 1000))
|
||
};
|
||
const updateRes = await apiRequest('PUT', `/api/groupCourse/${courseId}`, updateBody, adminToken);
|
||
console.log(' 调整时间为过去响应: status=' + updateRes.status);
|
||
|
||
if (updateRes.status === 200) {
|
||
record('3b. 调整课程时间(开课用)', 'PASS',
|
||
`startTime→${formatLocalTime(coachStartTime)}(3分钟前→教练可正常开课)`);
|
||
} else {
|
||
record('3b. 调整课程时间(开课用)', 'FAIL',
|
||
`status=${updateRes.status}, ${JSON.stringify(updateRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('3b. 调整课程时间(开课用)', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// 3c. 教练手动开课
|
||
if (coachToken && courseId) {
|
||
try {
|
||
const startRes = await apiRequest('POST', `/api/coach/courses/${courseId}/start`, {}, coachToken);
|
||
console.log(' 开课响应: status=' + startRes.status + ', body=' + JSON.stringify(startRes.data).substring(0, 300));
|
||
|
||
if (startRes.status === 200) {
|
||
const respData = startRes.data?.data || startRes.data;
|
||
record('3c. 手动开课', 'PASS',
|
||
`courseId=${courseId}, status=${respData?.status}, msg=${startRes.data?.message || ''}`);
|
||
} else if (startRes.status === 400) {
|
||
const msg = startRes.data?.message || '';
|
||
record('3c. 手动开课', 'FAIL', `status=400, msg=${msg}`);
|
||
} else {
|
||
record('3c. 手动开课', 'FAIL',
|
||
`status=${startRes.status}, ${JSON.stringify(startRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('3c. 手动开课', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// 3d. 调整课程结束时间:结课需要 endTime 在10分钟内(规则: now <= endTime + 10min)
|
||
// 把 endTime 设为2分钟前
|
||
const coachEndTime = new Date(Date.now() - 2 * 60 * 1000);
|
||
if (adminToken && courseId) {
|
||
try {
|
||
const updateBody = {
|
||
endTime: formatLocalTime(coachEndTime)
|
||
};
|
||
const updateRes = await apiRequest('PUT', `/api/groupCourse/${courseId}`, updateBody, adminToken);
|
||
console.log(' 调整结束时间响应: status=' + updateRes.status);
|
||
|
||
if (updateRes.status === 200) {
|
||
record('3d. 调整结束时间(结课用)', 'PASS',
|
||
`endTime→${formatLocalTime(coachEndTime)}(2分钟前→教练可结课)`);
|
||
} else {
|
||
record('3d. 调整结束时间(结课用)', 'FAIL',
|
||
`status=${updateRes.status}, ${JSON.stringify(updateRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('3d. 调整结束时间(结课用)', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// 3e. 教练手动结课
|
||
if (coachToken && courseId) {
|
||
try {
|
||
const endRes = await apiRequest('POST', `/api/coach/courses/${courseId}/end`, {}, coachToken);
|
||
console.log(' 结课响应: status=' + endRes.status + ', body=' + JSON.stringify(endRes.data).substring(0, 300));
|
||
|
||
if (endRes.status === 200) {
|
||
const respData = endRes.data?.data || endRes.data;
|
||
record('3e. 手动结课', 'PASS',
|
||
`courseId=${courseId}, status=${respData?.status}, msg=${endRes.data?.message || ''}`);
|
||
} else if (endRes.status === 400) {
|
||
const msg = endRes.data?.message || '';
|
||
record('3e. 手动结课', 'FAIL', `status=400, msg=${msg}`);
|
||
} else {
|
||
record('3e. 手动结课', 'FAIL',
|
||
`status=${endRes.status}, ${JSON.stringify(endRes.data).substring(0, 200)}`);
|
||
}
|
||
} catch (e) {
|
||
record('3e. 手动结课', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════
|
||
// 阶段4:验证与总结
|
||
// ═══════════════════════════════════════════
|
||
console.log('\n── 阶段4:最终验证 ──');
|
||
if (adminToken && courseId) {
|
||
try {
|
||
const finalRes = await apiRequest('GET', `/api/groupCourse/${courseId}/detail`, null, adminToken);
|
||
const fc = finalRes.data;
|
||
record('4. 最终课程状态', 'PASS',
|
||
`name="${fc.courseName}", status=${fc.status}, members=${fc.currentMembers}, startTime=${fc.startTime}`);
|
||
} catch (e) {
|
||
record('4. 最终课程状态', 'FAIL', e.message);
|
||
}
|
||
}
|
||
|
||
// ── 输出汇总 ──
|
||
results.endTime = new Date().toISOString();
|
||
console.log('\n═══════════════════════════════════════════');
|
||
console.log(' 测试结果汇总');
|
||
console.log('═══════════════════════════════════════════');
|
||
console.log(` 通过: ${results.passed} | 失败: ${results.failed} | 总计: ${results.steps.length}`);
|
||
console.log(` 课程ID: ${courseId || 'N/A'} 课程名: ${courseName}`);
|
||
console.log(` 二维码: ${results.qrPath || 'N/A'}`);
|
||
console.log(` 开始时间: ${results.startTime}`);
|
||
console.log(` 结束时间: ${results.endTime}`);
|
||
console.log('═══════════════════════════════════════════\n');
|
||
|
||
// 生成 Markdown 报告
|
||
generateMarkdownReport();
|
||
}
|
||
|
||
// ── 生成 Markdown 报告 ──
|
||
function generateMarkdownReport() {
|
||
const reportPath = path.join(__dirname, 'TEST_REPORT_full_flow.md');
|
||
const lines = [];
|
||
|
||
lines.push('# 全面端到端测试报告');
|
||
lines.push('');
|
||
lines.push('## 测试概要');
|
||
lines.push('');
|
||
lines.push('| 项目 | 值 |');
|
||
lines.push('|------|-----|');
|
||
lines.push('| 测试时间 | ' + results.startTime + ' ~ ' + results.endTime + ' |');
|
||
lines.push('| 总步骤数 | ' + results.steps.length + ' |');
|
||
lines.push('| 通过 | ' + results.passed + ' |');
|
||
lines.push('| 失败 | ' + results.failed + ' |');
|
||
lines.push('| 课程ID | ' + (results.courseId || 'N/A') + ' |');
|
||
lines.push('| 课程名称 | ' + (results.courseName || 'N/A') + ' |');
|
||
lines.push('| 二维码路径 | ' + (results.qrPath || 'N/A') + ' |');
|
||
lines.push('| API地址 | ' + API_BASE + ' |');
|
||
lines.push('');
|
||
|
||
lines.push('## 测试范围');
|
||
lines.push('');
|
||
lines.push('| 模块 | 项目 | 测试内容 |');
|
||
lines.push('|------|------|----------|');
|
||
lines.push('| 后台管理系统 | `gym-manage-web` | 管理员登录、创建团课(无封面、张教练)、修改团课时间、保存二维码 |');
|
||
lines.push('| 会员端 | `gym-manage-uniapp` | 会员登录、预约团课、扫码签到 |');
|
||
lines.push('| 教练端 | `gym-manage-coach-uniapp` | 教练登录、手动开课、手动结课 |');
|
||
lines.push('| 后端API | `gym-manage-api` | 所有操作通过REST API完成 |');
|
||
lines.push('');
|
||
|
||
lines.push('## 业务规则验证');
|
||
lines.push('');
|
||
lines.push('| 规则 | 条件 | 测试策略 |');
|
||
lines.push('|------|------|----------|');
|
||
lines.push('| 预约时间限制 | 需在开课前 >= 30分钟 | 创建课程startTime为5小时后,预约成功 |');
|
||
lines.push('| 签到时间窗口 | 开课前2小时 ~ 课程结束 | 调整startTime为1小时后,签到成功 |');
|
||
lines.push('| 教练开课 | 开课时间后10分钟内正常开课 | 调整startTime为3分钟前,开课成功 |');
|
||
lines.push('| 教练结课 | 结束时间后10分钟内结课 | 调整endTime为2分钟前,结课 |');
|
||
lines.push('');
|
||
|
||
lines.push('## 测试流程');
|
||
lines.push('');
|
||
lines.push('```');
|
||
lines.push('1. Admin: Login -> Get coach list -> Create course -> Save QR code');
|
||
lines.push('2. Member: Login -> Book course');
|
||
lines.push('3. Admin: Adjust startTime (for sign-in window)');
|
||
lines.push('4. Member: Sign in (scan QR)');
|
||
lines.push('5. Admin: Adjust startTime to past (for coach start)');
|
||
lines.push('6. Coach: Login -> Start course');
|
||
lines.push('7. Admin: Adjust endTime to past (for coach end)');
|
||
lines.push('8. Coach: End course');
|
||
lines.push('```');
|
||
lines.push('');
|
||
|
||
lines.push('## 详细步骤结果');
|
||
lines.push('');
|
||
lines.push('| # | 步骤 | 状态 | 详情 | 时间 |');
|
||
lines.push('|---|------|------|------|------|');
|
||
|
||
results.steps.forEach((s, i) => {
|
||
const icon = s.status === 'PASS' ? 'PASS' : (s.status === 'SKIP' ? 'SKIP' : 'FAIL');
|
||
const detail = (s.detail || '-').replace(/\|/g, '\\|');
|
||
lines.push('| ' + (i + 1) + ' | ' + s.step + ' | ' + icon + ' | ' + detail + ' | ' + (s.time || '-') + ' |');
|
||
});
|
||
|
||
lines.push('');
|
||
|
||
const failSteps = results.steps.filter(s => s.status === 'FAIL');
|
||
if (failSteps.length > 0) {
|
||
lines.push('## 失败步骤分析');
|
||
lines.push('');
|
||
failSteps.forEach(s => {
|
||
lines.push('- **' + s.step + '**: ' + (s.detail || '无详情'));
|
||
});
|
||
lines.push('');
|
||
}
|
||
|
||
lines.push('## 使用的API端点');
|
||
lines.push('');
|
||
lines.push('| 端点 | 方法 | 用途 | 认证 |');
|
||
lines.push('|------|------|------|------|');
|
||
lines.push('| `/api/auth/login` | POST | 管理员/教练登录 | HMAC签名 |');
|
||
lines.push('| `/api/coach/list` | GET | 获取教练列表 | JWT (Admin) |');
|
||
lines.push('| `/api/groupCourse` | POST | 创建团课 | JWT (Admin) |');
|
||
lines.push('| `/api/groupCourse/{id}` | PUT | 修改团课时间 | JWT (Admin) |');
|
||
lines.push('| `/api/groupCourse/{id}/detail` | GET | 获取课程详情(含二维码) | JWT (Admin) |');
|
||
lines.push('| `/api/member/auth/miniapp/login` | POST | 会员登录 | HMAC签名 |');
|
||
lines.push('| `/api/groupCourse/book` | POST | 预约团课 | JWT (Member) |');
|
||
lines.push('| `/api/groupCourse/signin/{memberId}` | POST | 扫码签到 | JWT (Member) |');
|
||
lines.push('| `/api/coach/courses/{courseId}/start` | POST | 教练手动开课 | JWT (Coach) |');
|
||
lines.push('| `/api/coach/courses/{courseId}/end` | POST | 教练手动结课 | JWT (Coach) |');
|
||
lines.push('');
|
||
|
||
lines.push('## 认证机制');
|
||
lines.push('');
|
||
lines.push('- **JWT Token**: `Authorization: Bearer {token}`');
|
||
lines.push('- **HMAC-SHA256**: `X-Signature`, `X-Timestamp`, `X-Nonce`');
|
||
lines.push('- **Secret Key**: `NovalonManageSystemSecretKey2026`');
|
||
lines.push('');
|
||
|
||
lines.push('## 测试账号');
|
||
lines.push('');
|
||
lines.push('| 角色 | 用户名 | 密码 |');
|
||
lines.push('|------|--------|------|');
|
||
lines.push('| 管理员 | admin | Test@123 |');
|
||
lines.push('| 会员 | (小程序code登录) | ' + MEMBER_CODE + ' |');
|
||
lines.push('| 教练 | coach_zhang | Test@123 |');
|
||
lines.push('');
|
||
|
||
lines.push('## 时间约束处理策略');
|
||
lines.push('');
|
||
lines.push('本测试通过后台API动态调整课程时间,绕过各步骤的时间限制:');
|
||
lines.push('');
|
||
lines.push('| 步骤 | 时间约束 | 处理方式 |');
|
||
lines.push('|------|----------|----------|');
|
||
lines.push('| 预约 | 需 >= 30分钟前 | 创建课程startTime=当前+5h |');
|
||
lines.push('| 签到 | 开课前2h ~ 课程结束 | PUT修改startTime=当前+1h |');
|
||
lines.push('| 开课 | 开课时间后10分钟内 | PUT修改startTime=当前-3min |');
|
||
lines.push('| 结课 | 结束时间后10分钟内 | PUT修改endTime=当前-2min |');
|
||
lines.push('');
|
||
lines.push('> **注意**: 使用 `formatLocalTime()` 发送本地时间(无时区),确保与服务器 LocalDateTime 一致。');
|
||
|
||
fs.writeFileSync(reportPath, lines.join('\n'), 'utf-8');
|
||
console.log('报告已保存: ' + reportPath);
|
||
}
|
||
|
||
// ── 运行 ──
|
||
main().catch(e => {
|
||
console.error('测试脚本异常:', e);
|
||
record('脚本异常', 'FAIL', e.message);
|
||
generateMarkdownReport();
|
||
});
|