#!/usr/bin/env node /** * 记账功能全面测试脚本 - QA自动化测试 * 测试覆盖:6个支出类别 + 6个收入类别 * 验证点:账单明细、首页、预算、统计页面、API、数据库一致性 * * 执行方式: node test-bookkeeping-full.js */ const http = require('http'); const API_BASE = 'http://localhost:3001'; const USER_ID = 1; let accountId = 1; // 默认使用第一个账户 // =========================== // 测试基础设施 // =========================== const results = { total: 0, passed: 0, failed: 0, warnings: 0, tests: [], bugs: [] }; function log(level, message) { const prefix = { 'INFO': '\x1b[36m[INFO]\x1b[0m', 'PASS': '\x1b[32m[PASS]\x1b[0m', 'FAIL': '\x1b[31m[FAIL]\x1b[0m', 'WARN': '\x1b[33m[WARN]\x1b[0m', 'TEST': '\x1b[35m[TEST]\x1b[0m', 'SUMMARY': '\x1b[1m\x1b[37m[SUMMARY]\x1b[0m', }[level] || `[${level}]`; console.log(`${prefix} ${message}`); } function recordTest(name, status, detail = '', bug = null) { results.total++; if (status === 'pass') results.passed++; else if (status === 'fail') results.failed++; else if (status === 'warn') results.warnings++; results.tests.push({ name, status, detail }); if (bug) results.bugs.push(bug); log(status === 'pass' ? 'PASS' : status === 'fail' ? 'FAIL' : 'WARN', `${name}${detail ? ' - ' + detail : ''}`); } function assertEqual(actual, expected, message) { results.total++; // 字符串类型直接比较 if (typeof expected === 'string' && typeof actual === 'string') { if (actual === expected) { results.passed++; log('PASS', message); return true; } else { results.failed++; log('FAIL', `${message} - 期望: "${expected}", 实际: "${actual}"`); return false; } } // 数值类型比较(处理浮点数) const actualNum = typeof actual === 'number' ? actual : parseFloat(actual); const expectedNum = typeof expected === 'number' ? expected : parseFloat(expected); if (Math.abs(actualNum - expectedNum) < 0.01) { results.passed++; log('PASS', message); return true; } else { results.failed++; log('FAIL', `${message} - 期望: ${expected}, 实际: ${actual}`); return false; } } function assertContains(actual, expected, message) { results.total++; if (actual && JSON.stringify(actual).includes(JSON.stringify(expected))) { results.passed++; log('PASS', message); return true; } else { results.failed++; log('FAIL', `${message} - 未找到预期数据`); return false; } } // =========================== // HTTP 请求封装 // =========================== function apiRequest(method, path, body = null) { return new Promise((resolve, reject) => { const url = new URL(path, API_BASE); const options = { hostname: url.hostname, port: url.port, path: url.pathname + url.search, method, headers: { 'Content-Type': 'application/json', }, }; const req = http.request(options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } catch (e) { resolve({ status: res.statusCode, data: data }); } }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } async function apiGet(path) { return apiRequest('GET', path); } async function apiPost(path, body) { return apiRequest('POST', path, body); } async function apiDelete(path) { return apiRequest('DELETE', path); } // =========================== // 测试阶段 0: 环境检查 // =========================== async function testEnvironmentCheck() { log('TEST', '========== 阶段0: 环境检查 =========='); try { const healthRes = await apiGet('/health'); recordTest('后端健康检查', healthRes.status === 200 ? 'pass' : 'fail', `状态码: ${healthRes.status}`); const apiRes = await apiGet('/api'); recordTest('API可用性', apiRes.status === 200 && apiRes.data.success ? 'pass' : 'fail', `API版本: ${apiRes.data?.data?.version}`); const usersRes = await apiGet(`/api/users`); recordTest('用户列表API', usersRes.status === 200 && usersRes.data.success ? 'pass' : 'fail'); const accountsRes = await apiGet(`/api/accounts?userId=${USER_ID}`); if (accountsRes.status === 200 && accountsRes.data.success && accountsRes.data.data.length > 0) { accountId = accountsRes.data.data[0].id; recordTest('账户可用性', 'pass', `使用账户ID: ${accountId}, 账户名: ${accountsRes.data.data[0].name}`); } else { recordTest('账户可用性', 'fail', '没有可用账户'); process.exit(1); } // 记录初始状态 const recordsRes = await apiGet(`/api/records?userId=${USER_ID}`); log('INFO', `当前已有 ${recordsRes.data.data.length} 条交易记录`); } catch (err) { recordTest('环境检查', 'fail', err.message); log('FAIL', '环境检查失败,请确保后端服务正在运行'); process.exit(1); } } // =========================== // 测试阶段 1: 6个支出类别测试 // =========================== async function testExpenseCategories() { log('TEST', '========== 阶段1: 6个支出类别添加测试 =========='); const expenseCategories = [ { category: '餐饮', amount: 35.50, description: '测试-午餐' }, { category: '交通', amount: 28.00, description: '测试-地铁充值' }, { category: '购物', amount: 199.99, description: '测试-日用品' }, { category: '娱乐', amount: 88.00, description: '测试-电影票' }, { category: '医疗', amount: 156.80, description: '测试-药品' }, { category: '其他', amount: 50.00, description: '测试-杂项' }, ]; const createdIds = []; for (const item of expenseCategories) { log('TEST', `--- 支出类别: ${item.category}, 金额: ¥${item.amount} ---`); const now = new Date(); const recordData = { userId: USER_ID, accountId: accountId, type: 'expense', amount: item.amount, category: item.category, description: item.description, date: now.toISOString(), }; // 1. 测试创建记录 const createRes = await apiPost('/api/records', recordData); if (createRes.status === 200 && createRes.data.success) { const recordId = createRes.data.data.id; createdIds.push(recordId); recordTest(`支出创建-${item.category}`, 'pass', `记录ID: ${recordId}`); // 2. 验证API返回的数据 assertEqual(createRes.data.data.type, 'expense', `${item.category} type字段`); assertEqual(createRes.data.data.category, item.category, `${item.category} category字段`); assertEqual(createRes.data.data.amount, item.amount, `${item.category} amount字段`); assertEqual(createRes.data.data.userId, USER_ID, `${item.category} userId字段`); recordTest(`支出数据完整性-${item.category}`, 'pass'); // 3. 验证单条记录查询 const getRes = await apiGet(`/api/records/${recordId}`); if (getRes.status === 200 && getRes.data.success) { assertEqual(getRes.data.data.id, recordId, `${item.category} 单条查询ID`); recordTest(`支出单条查询-${item.category}`, 'pass'); } else { recordTest(`支出单条查询-${item.category}`, 'fail', '无法查询到刚创建的记录'); } } else { recordTest(`支出创建-${item.category}`, 'fail', createRes.data?.message || `HTTP ${createRes.status}`); const bug = { id: `BUG-EXP-${item.category}`, severity: 'P0', title: `【高危】支出类别"${item.category}"创建失败`, environment: `后端: ${API_BASE}, 数据库: SQLite, 用户ID: ${USER_ID}`, steps: [ `调用 POST /api/records`, `请求体: ${JSON.stringify(recordData)}`, '返回错误' ], expected: '返回 success: true, 包含创建记录ID', actual: `success: false, message: ${createRes.data?.message || 'unknown'}`, suggestion: '检查后端records创建接口参数校验逻辑' }; results.bugs.push(bug); } // 等待100ms避免时间戳冲突 await new Promise(r => setTimeout(r, 100)); } // 验证所有支出记录都能查到 const allRecordsRes = await apiGet(`/api/records?userId=${USER_ID}`); if (allRecordsRes.data.success) { const expenseRecords = allRecordsRes.data.data.filter(r => r.type === 'expense'); const todayExpenseCount = expenseRecords.filter(r => { const d = new Date(r.date); const now = new Date(); return d.toDateString() === now.toDateString(); }).length; log('INFO', `今日支出记录数: ${todayExpenseCount} (预期至少6条新增)`); if (todayExpenseCount >= 6) { recordTest('支出记录总数验证', 'pass', `今日支出: ${todayExpenseCount}条`); } else { recordTest('支出记录总数验证', 'warn', `今日支出: ${todayExpenseCount}条 (可能包含之前已有的记录)`); } } return createdIds; } // =========================== // 测试阶段 2: 6个收入类别测试 // =========================== async function testIncomeCategories() { log('TEST', '========== 阶段2: 6个收入类别添加测试 =========='); const incomeCategories = [ { category: '工资', amount: 15000.00, description: '测试-月工资' }, { category: '奖金', amount: 2000.00, description: '测试-季度奖金' }, { category: '投资', amount: 800.00, description: '测试-股票收益' }, { category: '兼职', amount: 500.00, description: '测试-兼职收入' }, { category: '理财', amount: 350.00, description: '测试-理财收益' }, { category: '其他', amount: 100.00, description: '测试-其他收入' }, ]; const createdIds = []; for (const item of incomeCategories) { log('TEST', `--- 收入类别: ${item.category}, 金额: ¥${item.amount} ---`); const now = new Date(); const recordData = { userId: USER_ID, accountId: accountId, type: 'income', amount: item.amount, category: item.category, description: item.description, date: now.toISOString(), }; const createRes = await apiPost('/api/records', recordData); if (createRes.status === 200 && createRes.data.success) { const recordId = createRes.data.data.id; createdIds.push(recordId); recordTest(`收入创建-${item.category}`, 'pass', `记录ID: ${recordId}`); // 验证API返回数据 assertEqual(createRes.data.data.type, 'income', `${item.category} type字段`); assertEqual(createRes.data.data.category, item.category, `${item.category} category字段`); assertEqual(createRes.data.data.amount, item.amount, `${item.category} amount字段`); recordTest(`收入数据完整性-${item.category}`, 'pass'); } else { recordTest(`收入创建-${item.category}`, 'fail', createRes.data?.message || `HTTP ${createRes.status}`); } await new Promise(r => setTimeout(r, 100)); } return createdIds; } // =========================== // 测试阶段 3: 账单明细页筛选验证 // =========================== async function testRecordFilters() { log('TEST', '========== 阶段3: 账单明细筛选功能测试 =========='); // 测试全部筛选 const allRes = await apiGet(`/api/records?userId=${USER_ID}`); if (allRes.data.success) { recordTest('账单-全部筛选', 'pass', `总记录数: ${allRes.data.data.length}`); } else { recordTest('账单-全部筛选', 'fail'); } // 测试支出筛选 const expenseRes = await apiGet(`/api/records?userId=${USER_ID}&type=expense`); if (expenseRes.data.success) { const allExpense = expenseRes.data.data.every(r => r.type === 'expense'); if (allExpense) { recordTest('账单-支出筛选', 'pass', `支出记录: ${expenseRes.data.data.length}条`); } else { recordTest('账单-支出筛选', 'fail', '返回数据中包含非支出记录'); } } else { recordTest('账单-支出筛选', 'fail'); } // 测试收入筛选 const incomeRes = await apiGet(`/api/records?userId=${USER_ID}&type=income`); if (incomeRes.data.success) { const allIncome = incomeRes.data.data.every(r => r.type === 'income'); if (allIncome) { recordTest('账单-收入筛选', 'pass', `收入记录: ${incomeRes.data.data.length}条`); } else { recordTest('账单-收入筛选', 'fail', '返回数据中包含非收入记录'); } } else { recordTest('账单-收入筛选', 'fail'); } // 测试类别筛选 const categoryRes = await apiGet(`/api/records?userId=${USER_ID}&category=餐饮`); if (categoryRes.data.success) { const allDining = categoryRes.data.data.every(r => r.category === '餐饮'); if (allDining) { recordTest('账单-类别筛选(餐饮)', 'pass', `餐饮记录: ${categoryRes.data.data.length}条`); } else { recordTest('账单-类别筛选(餐饮)', 'fail', '返回数据中包含非餐饮记录'); } } else { recordTest('账单-类别筛选(餐饮)', 'fail'); } } // =========================== // 测试阶段 4: 首页数据一致性验证 // =========================== async function testDashboardConsistency() { log('TEST', '========== 阶段4: 首页数据一致性验证 =========='); const dashboardRes = await apiGet(`/api/dashboard/summary?userId=${USER_ID}`); if (!dashboardRes.data.success) { recordTest('首页Dashboard API', 'fail', '获取失败'); return; } const summary = dashboardRes.data.data; recordTest('首页Dashboard API', 'pass', `余额: ¥${summary.totalBalance}, 月收: ¥${summary.monthIncome}, 月支: ¥${summary.monthExpense}`); // 验证余额是否为各账户余额之和 const accountBalanceSum = summary.accounts.reduce((sum, a) => sum + parseFloat(a.balance), 0); assertEqual(summary.totalBalance, accountBalanceSum, '首页余额 = 各账户余额之和'); // 手动计算本月收支 const now = new Date(); const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const recordsRes = await apiGet(`/api/records?userId=${USER_ID}`); if (recordsRes.data.success) { let calcIncome = 0, calcExpense = 0; recordsRes.data.data.forEach(r => { const d = new Date(r.date); const rMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (rMonth === month) { if (r.type === 'income') calcIncome += parseFloat(r.amount); else calcExpense += parseFloat(r.amount); } }); assertEqual(summary.monthIncome, calcIncome, `本月收入一致性 (API=${summary.monthIncome}, 计算=${calcIncome.toFixed(2)})`); assertEqual(summary.monthExpense, calcExpense, `本月支出一致性 (API=${summary.monthExpense}, 计算=${calcExpense.toFixed(2)})`); } // 验证预算使用情况 if (summary.budgetUsage && summary.budgetUsage.length > 0) { recordTest('首页预算进度展示', 'pass', `预算类别数: ${summary.budgetUsage.length}`); for (const budget of summary.budgetUsage) { const pct = (budget.spent / budget.amount) * 100; assertEqual(Math.round(budget.percentage), Math.round(Math.min(pct, 100)), `${budget.category}预算百分比 (API=${budget.percentage}%, 计算=${pct.toFixed(1)}%)`); } } else { recordTest('首页预算进度展示', 'warn', '当前没有预算数据'); } } // =========================== // 测试阶段 5: 预算页数据验证 // =========================== async function testBudgetConsistency() { log('TEST', '========== 阶段5: 预算页数据一致性验证 =========='); const now = new Date(); const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const budgetsRes = await apiGet(`/api/budgets?userId=${USER_ID}&month=${month}`); if (!budgetsRes.data.success) { recordTest('预算API', 'fail'); return; } const budgets = budgetsRes.data.data; recordTest('预算API', 'pass', `预算数: ${budgets.length}`); // 获取所有支出记录 const expenseRes = await apiGet(`/api/records?userId=${USER_ID}&type=expense`); if (expenseRes.data.success) { const expenses = expenseRes.data.data; // 按类别汇总本月支出 const spendingByCategory = {}; expenses.forEach(r => { const d = new Date(r.date); const rMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (rMonth === month) { spendingByCategory[r.category] = (spendingByCategory[r.category] || 0) + parseFloat(r.amount); } }); // 验证预算使用计算 for (const budget of budgets) { const spent = spendingByCategory[budget.category] || 0; log('INFO', `预算类别 "${budget.category}": 预算=¥${budget.amount}, 已花=¥${spent.toFixed(2)}, 使用率=${(spent/budget.amount*100).toFixed(1)}%`); recordTest(`预算-${budget.category}`, 'pass', `已花: ¥${spent.toFixed(2)}, 预算: ¥${budget.amount}`); } // 检查新增的支出类别是否有对应预算 const budgetCategories = budgets.map(b => b.category); const expenseCategories = [...new Set(expenses.map(r => r.category))]; const missingBudgets = expenseCategories.filter(c => !budgetCategories.includes(c)); if (missingBudgets.length > 0) { recordTest('预算覆盖率', 'warn', `以下支出类别未设置预算: ${missingBudgets.join(', ')}`); } else { recordTest('预算覆盖率', 'pass', '所有支出类别都有预算'); } } } // =========================== // 测试阶段 6: 统计页数据验证 // =========================== async function testStatisticsConsistency() { log('TEST', '========== 阶段6: 统计页数据一致性验证 =========='); const now = new Date(); const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; // 月度统计API const statsRes = await apiGet(`/api/statistics/monthly?userId=${USER_ID}&month=${month}`); if (!statsRes.data.success) { recordTest('统计月度API', 'fail'); return; } const stats = statsRes.data.data; recordTest('统计月度API', 'pass', `月收: ¥${stats.totalIncome}, 月支: ¥${stats.totalExpense}, 结余: ¥${stats.balance}`); // 手动计算验证 const recordsRes = await apiGet(`/api/records?userId=${USER_ID}`); if (recordsRes.data.success) { let calcIncome = 0, calcExpense = 0; const categoryStats = {}; recordsRes.data.data.forEach(r => { const d = new Date(r.date); const rMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; if (rMonth === month) { const amt = parseFloat(r.amount); if (r.type === 'income') calcIncome += amt; else { calcExpense += amt; categoryStats[r.category] = (categoryStats[r.category] || 0) + amt; } } }); assertEqual(stats.totalIncome, calcIncome, '统计-总收入一致性'); assertEqual(stats.totalExpense, calcExpense, '统计-总支出一致性'); assertEqual(stats.balance, calcIncome - calcExpense, '统计-结余一致性'); // 验证分类统计 const apiCategoryMap = {}; stats.categoryStats.forEach(c => apiCategoryMap[c.category] = c.amount); let categoryMatch = true; for (const [cat, amount] of Object.entries(categoryStats)) { const apiAmount = apiCategoryMap[cat] || 0; if (Math.abs(apiAmount - amount) > 0.01) { recordTest(`统计-分类(${cat})`, 'fail', `API=${apiAmount}, 计算=${amount.toFixed(2)}`); categoryMatch = false; } else { recordTest(`统计-分类(${cat})`, 'pass', `¥${amount.toFixed(2)}`); } } if (categoryMatch) { recordTest('统计-全部分类一致性', 'pass', '所有分类金额一致'); } } // 趋势统计API const trendRes = await apiGet(`/api/statistics/trend?userId=${USER_ID}&startDate=${month}-01&endDate=${month}-31`); if (trendRes.data.success) { recordTest('统计趋势API', 'pass', `趋势数据点: ${trendRes.data.data.length}`); } else { // 深入诊断趋势API失败原因 const noDateTrendRes = await apiGet(`/api/statistics/trend?userId=${USER_ID}`); if (noDateTrendRes.data.success) { recordTest('统计趋势API', 'fail', '带日期参数时返回500错误'); results.bugs.push({ id: 'BUG-TREND-001', severity: 'P1', title: '【高危】统计趋势API在带日期范围参数时返回500错误', environment: `后端: ${API_BASE}, 数据库: SQLite (Prisma), 月份: ${month}`, steps: [ 'GET /api/statistics/trend?userId=1&startDate=2026-04-01&endDate=2026-04-30', '后端在records.forEach中调用r.date.toISOString()时出错', 'SQLite返回的date字段是字符串而非Date对象' ], expected: '返回按日期分组的收入/支出统计数据', actual: `success: false, message: "获取趋势统计失败"`, suggestion: '修复 index.js 趋势API第543行: 将 r.date.toISOString() 改为 (r.date instanceof Date ? r.date.toISOString() : new Date(r.date).toISOString())' }); } else { recordTest('统计趋势API', 'fail', '无条件查询也失败'); } } } // =========================== // 测试阶段 7: 账户余额联动验证 // =========================== async function testAccountBalanceConsistency() { log('TEST', '========== 阶段7: 账户余额联动验证 =========='); const accountsRes = await apiGet(`/api/accounts?userId=${USER_ID}`); if (!accountsRes.data.success) { recordTest('账户余额查询', 'fail'); return; } for (const account of accountsRes.data.data) { // 计算该账户的所有交易对余额的影响 const recordsRes = await apiGet(`/api/records?userId=${USER_ID}&accountId=${account.id}`); if (recordsRes.data.success) { let balanceChange = 0; recordsRes.data.data.forEach(r => { if (r.type === 'income') balanceChange += parseFloat(r.amount); else balanceChange -= parseFloat(r.amount); }); // 注意:初始余额未知,这里只验证余额不为负数(正常情况) const balance = parseFloat(account.balance); if (balance >= -0.01) { recordTest(`账户余额-${account.name}`, 'pass', `余额: ¥${balance}`); } else { recordTest(`账户余额-${account.name}`, 'warn', `余额: ¥${balance} (可能为负)`); } } } } // =========================== // 测试阶段 8: 边界条件测试 // =========================== async function testEdgeCases() { log('TEST', '========== 阶段8: 边界条件测试 =========='); // 测试必填字段缺失 const missingFieldRes = await apiPost('/api/records', { userId: USER_ID, accountId: accountId, type: 'expense', // 缺少 amount 和 category }); if (missingFieldRes.status === 400 && !missingFieldRes.data.success) { recordTest('边界-必填字段校验', 'pass', '正确返回400错误'); } else { recordTest('边界-必填字段校验', 'fail', `预期400, 实际${missingFieldRes.status}, success=${missingFieldRes.data?.success}`); results.bugs.push({ id: 'BUG-VALIDATION-001', severity: 'P1', title: '【中危】Records创建接口缺少必填字段校验', environment: `后端: ${API_BASE}`, steps: ['POST /api/records', '不传amount和category字段'], expected: '返回400错误,提示必填字段缺失', actual: `返回${missingFieldRes.status}, success=${missingFieldRes.data?.success}`, suggestion: '在后端添加必填字段校验逻辑' }); } // 测试负数金额 const negativeAmountRes = await apiPost('/api/records', { userId: USER_ID, accountId: accountId, type: 'expense', amount: -100, category: '餐饮', description: '测试负数金额', date: new Date().toISOString(), }); if (negativeAmountRes.status === 200 && negativeAmountRes.data.success) { recordTest('边界-负数金额', 'warn', '系统接受负数金额,可能导致数据异常'); results.bugs.push({ id: 'BUG-NEGATIVE-001', severity: 'P1', title: '【中危】Records接口未校验金额不能为负数', environment: `后端: ${API_BASE}`, steps: ['POST /api/records', 'amount=-100'], expected: '拒绝负数金额,返回400错误', actual: `接受负数金额,创建成功,ID=${negativeAmountRes.data.data.id}`, suggestion: '添加 amount > 0 的校验' }); } else { recordTest('边界-负数金额', 'pass', '正确拒绝负数金额'); } // 测试金额为0 const zeroAmountRes = await apiPost('/api/records', { userId: USER_ID, accountId: accountId, type: 'expense', amount: 0, category: '餐饮', description: '测试0金额', date: new Date().toISOString(), }); if (zeroAmountRes.status === 200 && zeroAmountRes.data.success) { recordTest('边界-零金额', 'warn', '系统接受0金额记录'); } else { recordTest('边界-零金额', 'pass', '拒绝0金额'); } } // =========================== // 测试阶段 9: 数据删除验证 // =========================== async function testDeleteRecords(createdIds) { log('TEST', '========== 阶段9: 删除记录+余额恢复验证 =========='); if (createdIds.length === 0) { log('WARN', '没有需要删除的记录'); return; } // 获取删除前账户余额 const accountsBeforeRes = await apiGet(`/api/accounts?userId=${USER_ID}`); const accountBefore = accountsBeforeRes.data.data.find(a => a.id === accountId); const balanceBefore = parseFloat(accountBefore.balance); // 删除第一条创建的记录 const deleteId = createdIds[0]; const deleteRes = await apiDelete(`/api/records/${deleteId}`); if (deleteRes.status === 200 && deleteRes.data.success) { recordTest('删除记录', 'pass', `删除记录ID: ${deleteId}`); } else { recordTest('删除记录', 'fail', `HTTP ${deleteRes.status}`); } // 验证记录已删除 const getDeletedRes = await apiGet(`/api/records/${deleteId}`); if (getDeletedRes.status === 404 || !getDeletedRes.data.success) { recordTest('删除记录验证', 'pass', '记录已被正确删除'); } else { recordTest('删除记录验证', 'fail', '删除后仍能查询到记录'); } // 验证账户余额恢复 const accountsAfterRes = await apiGet(`/api/accounts?userId=${USER_ID}`); const accountAfter = accountsAfterRes.data.data.find(a => a.id === accountId); const balanceAfter = parseFloat(accountAfter.balance); log('INFO', `账户余额变化: ¥${balanceBefore} -> ¥${balanceAfter}`); recordTest('删除后余额恢复', 'pass', `余额差值: ¥${(balanceAfter - balanceBefore).toFixed(2)}`); } // =========================== // 生成测试报告 // =========================== function generateReport() { log('TEST', '\n'); log('SUMMARY', '========== 测试报告 =========='); console.log(''); const passRate = results.total > 0 ? ((results.passed / results.total) * 100).toFixed(1) : 0; console.log(`\x1b[1m总测试数: ${results.total}\x1b[0m`); console.log(`\x1b[32m通过: ${results.passed}\x1b[0m`); console.log(`\x1b[31m失败: ${results.failed}\x1b[0m`); console.log(`\x1b[33m警告: ${results.warnings}\x1b[0m`); console.log(`\x1b[1m通过率: ${passRate}%\x1b[0m`); console.log(`\x1b[1m发现Bug: ${results.bugs.length}\x1b[0m`); if (results.bugs.length > 0) { console.log('\n\x1b[1m\x1b[31m--- Bug 列表 ---\x1b[0m'); results.bugs.forEach((bug, i) => { console.log(`\n${i + 1}. [${bug.severity}] ${bug.id}: ${bug.title}`); console.log(` 环境: ${bug.environment}`); console.log(` 步骤: ${bug.steps.join(' -> ')}`); console.log(` 预期: ${bug.expected}`); console.log(` 实际: ${bug.actual}`); console.log(` 建议: ${bug.suggestion}`); }); } // 生成JSON报告 const report = { timestamp: new Date().toISOString(), summary: { total: results.total, passed: results.passed, failed: results.failed, warnings: results.warnings, passRate: passRate + '%', }, bugs: results.bugs, tests: results.tests, }; const fs = require('fs'); const path = require('path'); const reportPath = path.join(__dirname, 'test-report-bookkeeping.json'); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8'); console.log(`\n详细报告已保存至: ${reportPath}`); // 判断是否通过 if (results.failed === 0) { console.log('\n\x1b[32m===========================================\x1b[0m'); console.log('\x1b[32m 测试通过! 所有核心用例执行成功\x1b[0m'); console.log('\x1b[32m===========================================\x1b[0m'); } else { console.log('\n\x1b[31m===========================================\x1b[0m'); console.log('\x1b[31m 测试失败! 存在 ' + results.failed + ' 个失败项\x1b[0m'); console.log('\x1b[31m===========================================\x1b[0m'); } } // =========================== // 主流程 // =========================== async function main() { log('TEST', '========================================'); log('TEST', ' 记账功能全面自动化测试'); log('TEST', ' 测试时间: ' + new Date().toLocaleString('zh-CN')); log('TEST', ' 后端地址: ' + API_BASE); log('TEST', '========================================\n'); try { // 阶段0: 环境检查 await testEnvironmentCheck(); // 阶段1: 支出类别测试 const expenseIds = await testExpenseCategories(); // 阶段2: 收入类别测试 const incomeIds = await testIncomeCategories(); const allCreatedIds = [...expenseIds, ...incomeIds]; // 阶段3: 账单明细筛选 await testRecordFilters(); // 阶段4: 首页数据一致性 await testDashboardConsistency(); // 阶段5: 预算页数据 await testBudgetConsistency(); // 阶段6: 统计页数据 await testStatisticsConsistency(); // 阶段7: 账户余额联动 await testAccountBalanceConsistency(); // 阶段8: 边界条件 await testEdgeCases(); // 阶段9: 删除记录验证 await testDeleteRecords(allCreatedIds); // 生成报告 generateReport(); } catch (err) { log('FAIL', '测试执行异常: ' + err.message); console.error(err); generateReport(); } } main();