feat: 个人记账与预算管理系统 MVP 初始版本

This commit is contained in:
2026-04-28 22:57:38 +08:00
commit d2b2e9887a
88 changed files with 29512 additions and 0 deletions
+853
View File
@@ -0,0 +1,853 @@
#!/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();