chore: remove test-bookkeeping-full.js
This commit is contained in:
@@ -1,853 +0,0 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user