feat: 个人记账与预算管理系统 MVP 初始版本
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Environment variables declared in this file are automatically made available to Prisma.
|
||||
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
|
||||
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
# 数据库连接字符串(SQLite 示例)
|
||||
DATABASE_URL="file:./dev.db"
|
||||
|
||||
# 后端服务端口(默认 3001)
|
||||
# PORT=3001
|
||||
@@ -0,0 +1,38 @@
|
||||
// 检查数据库数据
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function checkData() {
|
||||
console.log('=== 检查数据库数据 ===\n');
|
||||
|
||||
// 1. 检查用户
|
||||
const users = await prisma.user.findMany();
|
||||
console.log('用户:', users);
|
||||
|
||||
// 2. 检查账户
|
||||
const accounts = await prisma.account.findMany({ where: { userId: 1 } });
|
||||
console.log('\n账户:', accounts);
|
||||
console.log('账户余额总和:', accounts.reduce((sum, a) => sum + parseFloat(a.balance), 0));
|
||||
|
||||
// 3. 检查预算
|
||||
const budgets = await prisma.budget.findMany({ where: { userId: 1 } });
|
||||
console.log('\n预算:', budgets);
|
||||
console.log('预算金额总和:', budgets.reduce((sum, b) => sum + parseFloat(b.amount), 0));
|
||||
|
||||
// 4. 检查本月记录
|
||||
const now = new Date();
|
||||
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
const startDate = new Date(month + '-01');
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const records = await prisma.record.findMany({
|
||||
where: { userId: 1, date: { gte: startDate, lte: endDate } }
|
||||
});
|
||||
console.log('\n本月记录:', records.length, '条');
|
||||
console.log('总收入:', records.filter(r => r.type === 'income').reduce((sum, r) => sum + parseFloat(r.amount), 0));
|
||||
console.log('总支出:', records.filter(r => r.type === 'expense').reduce((sum, r) => sum + parseFloat(r.amount), 0));
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
checkData().catch(console.error);
|
||||
@@ -0,0 +1,205 @@
|
||||
// Initialize test data
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 开始初始化测试数据...');
|
||||
|
||||
// 1. 创建测试用户
|
||||
console.log('📝 创建测试用户...');
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: '测试用户',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
});
|
||||
console.log(`✅ 用户创建成功: ${user.name} (ID: ${user.id})`);
|
||||
|
||||
// 2. 创建测试账户
|
||||
console.log('💰 创建测试账户...');
|
||||
const accounts = await Promise.all([
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '支付宝',
|
||||
type: 'payment',
|
||||
color: '#1890FF',
|
||||
balance: 5000,
|
||||
},
|
||||
}),
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '微信钱包',
|
||||
type: 'payment',
|
||||
color: '#52C41A',
|
||||
balance: 3000,
|
||||
},
|
||||
}),
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '招商银行',
|
||||
type: 'bank',
|
||||
color: '#FAAD14',
|
||||
balance: 10000,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
console.log(`✅ ${accounts.length} 个账户创建成功`);
|
||||
|
||||
// 3. 创建测试交易记录
|
||||
console.log('📊 创建测试交易记录...');
|
||||
const today = new Date();
|
||||
|
||||
const records = await Promise.all([
|
||||
// 收入记录
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id,
|
||||
type: 'income',
|
||||
amount: 8500,
|
||||
category: '工资',
|
||||
description: '2026年4月工资',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), 1),
|
||||
},
|
||||
}),
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[2].id,
|
||||
type: 'income',
|
||||
amount: 500,
|
||||
category: '奖金',
|
||||
description: '绩效奖金',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), 5),
|
||||
},
|
||||
}),
|
||||
|
||||
// 支出记录
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id,
|
||||
type: 'expense',
|
||||
amount: 68,
|
||||
category: '餐饮',
|
||||
description: '午饭',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
},
|
||||
}),
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[1].id,
|
||||
type: 'expense',
|
||||
amount: 25,
|
||||
category: '交通',
|
||||
description: '打车',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
},
|
||||
}),
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id,
|
||||
type: 'expense',
|
||||
amount: 299,
|
||||
category: '购物',
|
||||
description: '买衣服',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 2),
|
||||
},
|
||||
}),
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[1].id,
|
||||
type: 'expense',
|
||||
amount: 128,
|
||||
category: '娱乐',
|
||||
description: '游戏充值',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 3),
|
||||
},
|
||||
}),
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id,
|
||||
type: 'expense',
|
||||
amount: 38,
|
||||
category: '餐饮',
|
||||
description: '星巴克',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 4),
|
||||
},
|
||||
}),
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[1].id,
|
||||
type: 'expense',
|
||||
amount: 150,
|
||||
category: '娱乐',
|
||||
description: '电影票',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 5),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
console.log(`✅ ${records.length} 条交易记录创建成功`);
|
||||
|
||||
// 4. 创建测试预算
|
||||
console.log('📋 创建测试预算...');
|
||||
const budgets = await Promise.all([
|
||||
prisma.budget.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: '餐饮',
|
||||
amount: 1500,
|
||||
month: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`,
|
||||
},
|
||||
}),
|
||||
prisma.budget.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: '交通',
|
||||
amount: 500,
|
||||
month: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`,
|
||||
},
|
||||
}),
|
||||
prisma.budget.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: '购物',
|
||||
amount: 1000,
|
||||
month: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`,
|
||||
},
|
||||
}),
|
||||
prisma.budget.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: '娱乐',
|
||||
amount: 500,
|
||||
month: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
console.log(`✅ ${budgets.length} 个预算创建成功`);
|
||||
|
||||
console.log('\n🎉 测试数据初始化完成!');
|
||||
console.log('\n📌 使用信息:');
|
||||
console.log(`- User ID: ${user.id}`);
|
||||
console.log(`- Email: ${user.email}`);
|
||||
console.log(`- 账户数: ${accounts.length}`);
|
||||
console.log(`- 记录数: ${records.length}`);
|
||||
console.log(`- 预算数: ${budgets.length}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ 初始化失败:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
Generated
+1297
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "personal-finance-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Personal Finance Budget System Backend",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "node prisma/seed.js"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.5.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.5.0"
|
||||
},
|
||||
"keywords": ["express", "prisma", "sqlite", "finance"],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// 用户表
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
email String @unique
|
||||
createdAt DateTime @default(now())
|
||||
accounts Account[]
|
||||
budgets Budget[]
|
||||
records Record[]
|
||||
}
|
||||
|
||||
// 账户表
|
||||
model Account {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
name String // 账户名称(支付宝、微信、银行卡等)
|
||||
balance Decimal @default(0) // 当前余额
|
||||
type String // 账户类型
|
||||
color String // 显示颜色
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
records Record[]
|
||||
}
|
||||
|
||||
// 交易记录表
|
||||
model Record {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
accountId Int
|
||||
type String // 'income' | 'expense'
|
||||
amount Decimal
|
||||
category String // 消费类别
|
||||
description String? // 备注
|
||||
date DateTime
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
account Account @relation(fields: [accountId], references: [id])
|
||||
}
|
||||
|
||||
// 预算表
|
||||
model Budget {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
category String // 预算类别
|
||||
amount Decimal // 预算金额
|
||||
month String // 预算月份(YYYY-MM)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Prisma Seed Script - 初始化测试数据
|
||||
* 用于恢复账务系统的测试数据
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('开始恢复测试数据...');
|
||||
|
||||
// 清空现有数据(按依赖顺序)
|
||||
await prisma.record.deleteMany();
|
||||
await prisma.budget.deleteMany();
|
||||
await prisma.account.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
|
||||
// 1. 创建测试用户
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: '测试用户',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
});
|
||||
console.log(`创建用户: ${user.name} (ID: ${user.id})`);
|
||||
|
||||
// 2. 创建账户(支付宝、微信、银行卡)
|
||||
const accounts = await Promise.all([
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '支付宝',
|
||||
balance: 5000.00,
|
||||
type: 'Alipay',
|
||||
color: '#1677FF',
|
||||
},
|
||||
}),
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '微信支付',
|
||||
balance: 3000.00,
|
||||
type: 'WeChat',
|
||||
color: '#07C160',
|
||||
},
|
||||
}),
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '银行卡',
|
||||
balance: 10000.00,
|
||||
type: 'BankCard',
|
||||
color: '#722ED1',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
console.log(`创建 ${accounts.length} 个账户: ${accounts.map(a => a.name).join(', ')}`);
|
||||
|
||||
// 3. 创建交易记录(至少6条)
|
||||
const records = await Promise.all([
|
||||
// 支付宝 - 支出
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id, // 支付宝
|
||||
type: 'expense',
|
||||
amount: 128.50,
|
||||
category: '餐饮',
|
||||
description: '午餐',
|
||||
date: new Date('2026-04-25'),
|
||||
},
|
||||
}),
|
||||
// 微信 - 支出
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[1].id, // 微信
|
||||
type: 'expense',
|
||||
amount: 45.00,
|
||||
category: '交通',
|
||||
description: '地铁',
|
||||
date: new Date('2026-04-25'),
|
||||
},
|
||||
}),
|
||||
// 银行卡 - 收入
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[2].id, // 银行卡
|
||||
type: 'income',
|
||||
amount: 15000.00,
|
||||
category: '工资',
|
||||
description: '月薪',
|
||||
date: new Date('2026-04-20'),
|
||||
},
|
||||
}),
|
||||
// 支付宝 - 支出
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id, // 支付宝
|
||||
type: 'expense',
|
||||
amount: 299.00,
|
||||
category: '购物',
|
||||
description: '网购商品',
|
||||
date: new Date('2026-04-23'),
|
||||
},
|
||||
}),
|
||||
// 微信 - 收入
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[1].id, // 微信
|
||||
type: 'income',
|
||||
amount: 500.00,
|
||||
category: '转账',
|
||||
description: '朋友还款',
|
||||
date: new Date('2026-04-22'),
|
||||
},
|
||||
}),
|
||||
// 银行卡 - 支出
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[2].id, // 银行卡
|
||||
type: 'expense',
|
||||
amount: 2000.00,
|
||||
category: '房租',
|
||||
description: '月租',
|
||||
date: new Date('2026-04-01'),
|
||||
},
|
||||
}),
|
||||
// 支付宝 - 支出(额外)
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id,
|
||||
type: 'expense',
|
||||
amount: 56.80,
|
||||
category: '娱乐',
|
||||
description: '电影票',
|
||||
date: new Date('2026-04-24'),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
console.log(`创建 ${records.length} 条交易记录`);
|
||||
|
||||
// 4. 创建预算
|
||||
await prisma.budget.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: '餐饮',
|
||||
amount: 2000.00,
|
||||
month: '2026-04',
|
||||
},
|
||||
});
|
||||
console.log('创建预算数据');
|
||||
|
||||
console.log('\n✅ 测试数据恢复完成!');
|
||||
console.log(`- 用户: 1 个`);
|
||||
console.log(`- 账户: ${accounts.length} 个`);
|
||||
console.log(`- 交易记录: ${records.length} 条`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ 数据恢复失败:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
import http from 'http'
|
||||
|
||||
const BASE_URL = 'http://localhost:3001'
|
||||
|
||||
// 测试结果收集
|
||||
const testResults = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
tests: []
|
||||
}
|
||||
|
||||
// HTTP请求辅助函数
|
||||
function request(method, path, data = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, BASE_URL)
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const req = http.request(url, options, (res) => {
|
||||
let body = ''
|
||||
res.on('data', (chunk) => { body += chunk })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(body)
|
||||
resolve({ status: res.statusCode, data: response })
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, data: body })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
|
||||
if (data) {
|
||||
req.write(JSON.stringify(data))
|
||||
}
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
// 断言函数
|
||||
function assert(condition, testName, message = '') {
|
||||
const result = {
|
||||
name: testName,
|
||||
passed: condition,
|
||||
message: message || (condition ? '通过' : '失败')
|
||||
}
|
||||
testResults.tests.push(result)
|
||||
if (condition) {
|
||||
testResults.passed++
|
||||
console.log(`✅ ${testName}`)
|
||||
} else {
|
||||
testResults.failed++
|
||||
console.log(`❌ ${testName}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查响应格式
|
||||
function checkResponseFormat(response, testNamePrefix) {
|
||||
assert(
|
||||
response.data.hasOwnProperty('success'),
|
||||
`${testNamePrefix} - 响应包含success字段`,
|
||||
`响应缺少success字段,实际响应: ${JSON.stringify(response.data)}`
|
||||
)
|
||||
assert(
|
||||
response.data.hasOwnProperty('data'),
|
||||
`${testNamePrefix} - 响应包含data字段`,
|
||||
`响应缺少data字段,实际响应: ${JSON.stringify(response.data)}`
|
||||
)
|
||||
assert(
|
||||
response.data.hasOwnProperty('message'),
|
||||
`${testNamePrefix} - 响应包含message字段`,
|
||||
`响应缺少message字段,实际响应: ${JSON.stringify(response.data)}`
|
||||
)
|
||||
}
|
||||
|
||||
// 主测试流程
|
||||
async function runTests() {
|
||||
console.log('='.repeat(60))
|
||||
console.log('个人理财系统 - API集成测试')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
try {
|
||||
// 1. 健康检查
|
||||
console.log('\n【1】健康检查测试')
|
||||
console.log('-'.repeat(40))
|
||||
const healthRes = await request('GET', '/health')
|
||||
assert(healthRes.status === 200, '健康检查状态码200')
|
||||
checkResponseFormat(healthRes, '健康检查')
|
||||
assert(healthRes.data.success === true, '健康检查success为true')
|
||||
|
||||
// 2. 基础API信息
|
||||
console.log('\n【2】API基础信息测试')
|
||||
console.log('-'.repeat(40))
|
||||
const apiRes = await request('GET', '/api')
|
||||
assert(apiRes.status === 200, 'API信息状态码200')
|
||||
checkResponseFormat(apiRes, 'API信息')
|
||||
|
||||
// 3. 用户接口测试
|
||||
console.log('\n【3】用户接口测试')
|
||||
console.log('-'.repeat(40))
|
||||
|
||||
// 创建测试用户
|
||||
const createUserRes = await request('POST', '/api/users', {
|
||||
name: '测试用户',
|
||||
email: 'test' + Date.now() + '@example.com'
|
||||
})
|
||||
assert(createUserRes.status === 200, '创建用户状态码200')
|
||||
checkResponseFormat(createUserRes, '创建用户')
|
||||
assert(createUserRes.data.success === true, '创建用户success为true')
|
||||
assert(createUserRes.data.data !== null, '创建用户返回数据不为null')
|
||||
assert(createUserRes.data.data.id !== undefined, '创建用户返回ID')
|
||||
|
||||
const testUserId = createUserRes.data.data.id
|
||||
|
||||
// 获取用户列表
|
||||
const getUsersRes = await request('GET', '/api/users')
|
||||
assert(getUsersRes.status === 200, '获取用户列表状态码200')
|
||||
checkResponseFormat(getUsersRes, '获取用户列表')
|
||||
assert(Array.isArray(getUsersRes.data.data), '用户列表为数组')
|
||||
|
||||
// 4. 账户接口测试
|
||||
console.log('\n【4】账户接口测试')
|
||||
console.log('-'.repeat(40))
|
||||
|
||||
// 创建账户
|
||||
const createAccountRes = await request('POST', '/api/accounts', {
|
||||
userId: testUserId,
|
||||
name: '测试账户',
|
||||
type: 'cash',
|
||||
balance: 1000
|
||||
})
|
||||
assert(createAccountRes.status === 200, '创建账户状态码200')
|
||||
checkResponseFormat(createAccountRes, '创建账户')
|
||||
assert(createAccountRes.data.success === true, '创建账户success为true')
|
||||
assert(createAccountRes.data.data !== null, '创建账户返回数据不为null')
|
||||
|
||||
const testAccountId = createAccountRes.data.data.id
|
||||
|
||||
// 获取账户列表
|
||||
const getAccountsRes = await request('GET', `/api/accounts?userId=${testUserId}`)
|
||||
assert(getAccountsRes.status === 200, '获取账户列表状态码200')
|
||||
checkResponseFormat(getAccountsRes, '获取账户列表')
|
||||
assert(Array.isArray(getAccountsRes.data.data), '账户列表为数组')
|
||||
|
||||
// 获取单个账户
|
||||
const getAccountRes = await request('GET', `/api/accounts/${testAccountId}`)
|
||||
assert(getAccountRes.status === 200, '获取单个账户状态码200')
|
||||
checkResponseFormat(getAccountRes, '获取单个账户')
|
||||
assert(getAccountRes.data.data.id === testAccountId, '账户ID匹配')
|
||||
|
||||
// 更新账户
|
||||
const updateAccountRes = await request('PUT', `/api/accounts/${testAccountId}`, {
|
||||
name: '测试账户(已更新)',
|
||||
balance: 2000
|
||||
})
|
||||
assert(updateAccountRes.status === 200, '更新账户状态码200')
|
||||
checkResponseFormat(updateAccountRes, '更新账户')
|
||||
assert(updateAccountRes.data.data.name === '测试账户(已更新)', '账户名称已更新')
|
||||
|
||||
// 5. 交易记录接口测试
|
||||
console.log('\n【5】交易记录接口测试')
|
||||
console.log('-'.repeat(40))
|
||||
|
||||
// 创建收入记录
|
||||
const createIncomeRes = await request('POST', '/api/records', {
|
||||
userId: testUserId,
|
||||
accountId: testAccountId,
|
||||
type: 'income',
|
||||
amount: 500,
|
||||
category: '工资',
|
||||
description: '测试收入'
|
||||
})
|
||||
assert(createIncomeRes.status === 200, '创建收入记录状态码200')
|
||||
checkResponseFormat(createIncomeRes, '创建收入记录')
|
||||
assert(createIncomeRes.data.success === true, '创建收入记录success为true')
|
||||
|
||||
const testRecordId = createIncomeRes.data.data.id
|
||||
|
||||
// 创建支出记录
|
||||
const createExpenseRes = await request('POST', '/api/records', {
|
||||
userId: testUserId,
|
||||
accountId: testAccountId,
|
||||
type: 'expense',
|
||||
amount: 200,
|
||||
category: '餐饮',
|
||||
description: '测试支出'
|
||||
})
|
||||
assert(createExpenseRes.status === 200, '创建支出记录状态码200')
|
||||
checkResponseFormat(createExpenseRes, '创建支出记录')
|
||||
|
||||
// 获取记录列表
|
||||
const getRecordsRes = await request('GET', `/api/records?userId=${testUserId}`)
|
||||
assert(getRecordsRes.status === 200, '获取记录列表状态码200')
|
||||
checkResponseFormat(getRecordsRes, '获取记录列表')
|
||||
assert(Array.isArray(getRecordsRes.data.data), '记录列表为数组')
|
||||
|
||||
// 获取单个记录
|
||||
const getRecordRes = await request('GET', `/api/records/${testRecordId}`)
|
||||
assert(getRecordRes.status === 200, '获取单个记录状态码200')
|
||||
checkResponseFormat(getRecordRes, '获取单个记录')
|
||||
|
||||
// 更新记录
|
||||
const updateRecordRes = await request('PUT', `/api/records/${testRecordId}`, {
|
||||
description: '测试收入(已更新)'
|
||||
})
|
||||
assert(updateRecordRes.status === 200, '更新记录状态码200')
|
||||
checkResponseFormat(updateRecordRes, '更新记录')
|
||||
|
||||
// 6. 预算接口测试
|
||||
console.log('\n【6】预算接口测试')
|
||||
console.log('-'.repeat(40))
|
||||
|
||||
const currentMonth = new Date().toISOString().slice(0, 7)
|
||||
|
||||
// 创建预算
|
||||
const createBudgetRes = await request('POST', '/api/budgets', {
|
||||
userId: testUserId,
|
||||
category: '餐饮',
|
||||
amount: 1000,
|
||||
month: currentMonth
|
||||
})
|
||||
assert(createBudgetRes.status === 200, '创建预算状态码200')
|
||||
checkResponseFormat(createBudgetRes, '创建预算')
|
||||
assert(createBudgetRes.data.success === true, '创建预算success为true')
|
||||
|
||||
const testBudgetId = createBudgetRes.data.data.id
|
||||
|
||||
// 获取预算列表
|
||||
const getBudgetsRes = await request('GET', `/api/budgets?userId=${testUserId}`)
|
||||
assert(getBudgetsRes.status === 200, '获取预算列表状态码200')
|
||||
checkResponseFormat(getBudgetsRes, '获取预算列表')
|
||||
assert(Array.isArray(getBudgetsRes.data.data), '预算列表为数组')
|
||||
|
||||
// 获取单个预算
|
||||
const getBudgetRes = await request('GET', `/api/budgets/${testBudgetId}`)
|
||||
assert(getBudgetRes.status === 200, '获取单个预算状态码200')
|
||||
checkResponseFormat(getBudgetRes, '获取单个预算')
|
||||
|
||||
// 更新预算
|
||||
const updateBudgetRes = await request('PUT', `/api/budgets/${testBudgetId}`, {
|
||||
amount: 1500
|
||||
})
|
||||
assert(updateBudgetRes.status === 200, '更新预算状态码200')
|
||||
checkResponseFormat(updateBudgetRes, '更新预算')
|
||||
|
||||
// 7. 统计接口测试
|
||||
console.log('\n【7】统计接口测试')
|
||||
console.log('-'.repeat(40))
|
||||
|
||||
// 月度统计
|
||||
const monthlyStatsRes = await request('GET', `/api/statistics/monthly?userId=${testUserId}&month=${currentMonth}`)
|
||||
assert(monthlyStatsRes.status === 200, '月度统计状态码200')
|
||||
checkResponseFormat(monthlyStatsRes, '月度统计')
|
||||
assert(monthlyStatsRes.data.data.totalIncome !== undefined, '月度统计包含总收入')
|
||||
assert(monthlyStatsRes.data.data.totalExpense !== undefined, '月度统计包含总支出')
|
||||
|
||||
// 趋势统计
|
||||
const trendRes = await request('GET', `/api/statistics/trend?userId=${testUserId}`)
|
||||
assert(trendRes.status === 200, '趋势统计状态码200')
|
||||
checkResponseFormat(trendRes, '趋势统计')
|
||||
assert(Array.isArray(trendRes.data.data), '趋势统计返回数组')
|
||||
|
||||
// 8. 仪表盘接口测试
|
||||
console.log('\n【8】仪表盘接口测试')
|
||||
console.log('-'.repeat(40))
|
||||
|
||||
const dashboardRes = await request('GET', `/api/dashboard/summary?userId=${testUserId}`)
|
||||
assert(dashboardRes.status === 200, '仪表盘状态码200')
|
||||
checkResponseFormat(dashboardRes, '仪表盘')
|
||||
assert(dashboardRes.data.data.totalBalance !== undefined, '仪表盘包含总余额')
|
||||
assert(dashboardRes.data.data.monthIncome !== undefined, '仪表盘包含本月收入')
|
||||
assert(dashboardRes.data.data.monthExpense !== undefined, '仪表盘包含本月支出')
|
||||
|
||||
// 9. 清理测试数据
|
||||
console.log('\n【9】清理测试数据')
|
||||
console.log('-'.repeat(40))
|
||||
|
||||
// 删除记录
|
||||
await request('DELETE', `/api/records/${testRecordId}`)
|
||||
|
||||
// 删除预算
|
||||
await request('DELETE', `/api/budgets/${testBudgetId}`)
|
||||
|
||||
// 删除账户
|
||||
await request('DELETE', `/api/accounts/${testAccountId}`)
|
||||
|
||||
console.log('✅ 测试数据清理完成')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 测试过程出错:', error)
|
||||
}
|
||||
|
||||
// 输出测试报告
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('测试报告摘要')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`总测试数: ${testResults.tests.length}`)
|
||||
console.log(`✅ 通过: ${testResults.passed}`)
|
||||
console.log(`❌ 失败: ${testResults.failed}`)
|
||||
console.log(`通过率: ${((testResults.passed / testResults.tests.length) * 100).toFixed(1)}%`)
|
||||
console.log('='.repeat(60))
|
||||
|
||||
return testResults
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests().then(results => {
|
||||
process.exit(results.failed > 0 ? 1 : 0)
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
// Simple test to create test user
|
||||
const https = require('http');
|
||||
|
||||
const data = JSON.stringify({
|
||||
name: '测试用户',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3001,
|
||||
path: '/api/users',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': data.length
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
console.log(`Status Code: ${res.statusCode}`);
|
||||
|
||||
res.on('data', (d) => {
|
||||
process.stdout.write(d);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 测试脚本:账单排序问题深度验证
|
||||
*
|
||||
* 测试目的:
|
||||
* 1. 验证后端 records 接口的排序逻辑(orderBy: date vs createdAt)
|
||||
* 2. 创建一条新的"兼职"收入记录,检查其date和createdAt字段
|
||||
* 3. 验证前端日期输入框类型导致的时间丢失问题
|
||||
* 4. 验证首页"最近记录"的数据同步
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
|
||||
const BASE_URL = 'http://localhost:3001';
|
||||
|
||||
function apiRequest(method, path, body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, BASE_URL);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname + url.search,
|
||||
method: 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, body: JSON.parse(data) });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, body: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function logSeparator() {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
logSeparator();
|
||||
console.log('🚀 开始执行账单排序问题深度测试...');
|
||||
logSeparator();
|
||||
|
||||
// ==========================================
|
||||
// 测试1: 获取现有记录,检查排序逻辑
|
||||
// ==========================================
|
||||
const recordsBefore = await apiRequest('GET', '/api/records?userId=1');
|
||||
|
||||
console.log('\n📋 【测试1】获取现有记录 - 检查排序逻辑');
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
if (recordsBefore.status !== 200) {
|
||||
console.log(`❌ API返回错误状态: ${recordsBefore.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const records = recordsBefore.body.data || [];
|
||||
console.log(`📊 记录总数: ${records.length}`);
|
||||
console.log(`\n📋 现有记录列表 (按后端排序):`);
|
||||
records.forEach((r, i) => {
|
||||
const date = new Date(r.date);
|
||||
const created = new Date(r.createdAt);
|
||||
console.log(` [${i + 1}] ${r.category} | ¥${r.amount} | date=${date.toLocaleString('zh-CN')} | createdAt=${created.toLocaleString('zh-CN')}`);
|
||||
});
|
||||
|
||||
// 检查排序
|
||||
const dates = records.map(r => new Date(r.date).getTime());
|
||||
const isDateDesc = dates.every((d, i) => i === 0 || dates[i - 1] >= d);
|
||||
console.log(`\n🔍 后端排序检查:`);
|
||||
console.log(` 按 date 降序: ${isDateDesc ? '✅ 是' : '❌ 否'}`);
|
||||
|
||||
// 检查是否有相同date的记录
|
||||
const dateGroups = {};
|
||||
records.forEach(r => {
|
||||
const dateKey = new Date(r.date).toLocaleDateString('zh-CN');
|
||||
if (!dateGroups[dateKey]) dateGroups[dateKey] = [];
|
||||
dateGroups[dateKey].push(r);
|
||||
});
|
||||
|
||||
console.log(`\n 相同日期分组:`);
|
||||
Object.entries(dateGroups).forEach(([date, recs]) => {
|
||||
console.log(` ${date}: ${recs.length}条记录`);
|
||||
recs.forEach(r => {
|
||||
console.log(` - ${r.category} ¥${r.amount} (createdAt: ${new Date(r.createdAt).toLocaleString('zh-CN')})`);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 测试2: 模拟前端创建"兼职 +50元"记录
|
||||
// ==========================================
|
||||
console.log(`\n\n📋 【测试2】模拟前端提交"兼职 +50元 技术支持"`);
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
// 前端日期表单使用 new Date().toISOString().split('T')[0],即只传日期部分
|
||||
const today = new Date();
|
||||
const dateOnly = today.toISOString().split('T')[0];
|
||||
|
||||
console.log(`📌 模拟前端提交的 date 值: "${dateOnly}" (type=date 格式)`);
|
||||
console.log(`📌 提交时间: ${today.toLocaleString('zh-CN')}`);
|
||||
|
||||
const createResult = await apiRequest('POST', '/api/records', {
|
||||
userId: 1,
|
||||
accountId: 1, // 支付宝
|
||||
type: 'income',
|
||||
amount: 50,
|
||||
category: '兼职',
|
||||
description: '技术支持',
|
||||
date: dateOnly // 前端只传日期,不传时间
|
||||
});
|
||||
|
||||
let newRecordId = null;
|
||||
if (createResult.status === 200) {
|
||||
newRecordId = createResult.body.data.id;
|
||||
const newRecord = createResult.body.data;
|
||||
const dateVal = new Date(newRecord.date);
|
||||
const createdVal = new Date(newRecord.createdAt);
|
||||
console.log(` ✅ 创建成功`);
|
||||
console.log(` ID: ${newRecord.id}`);
|
||||
console.log(` date字段: ${dateVal.toLocaleString('zh-CN')} (ISO: ${newRecord.date})`);
|
||||
console.log(` createdAt字段: ${createdVal.toLocaleString('zh-CN')} (ISO: ${newRecord.createdAt})`);
|
||||
console.log(` ⚠️ date 时间为: ${dateVal.getHours()}:${dateVal.getMinutes().toString().padStart(2, '0')}`);
|
||||
console.log(` 🔴 问题确认: date字段时间部分为 00:00 或 08:00,不是当前实际时间!`);
|
||||
} else {
|
||||
console.log(` ❌ 创建失败: ${JSON.stringify(createResult.body)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 测试3: 验证新记录在列表中的排序位置
|
||||
// ==========================================
|
||||
const recordsAfter = await apiRequest('GET', '/api/records?userId=1');
|
||||
|
||||
console.log(`\n\n📋 【测试3】验证新记录在列表中的排序位置`);
|
||||
console.log('-'.repeat(60));
|
||||
|
||||
const allRecords = recordsAfter.body.data || [];
|
||||
console.log(`📊 创建后记录总数: ${allRecords.length}`);
|
||||
console.log(`\n📋 记录列表 (后端排序结果):`);
|
||||
allRecords.forEach((r, i) => {
|
||||
const date = new Date(r.date);
|
||||
const created = new Date(r.createdAt);
|
||||
const isNew = r.id === newRecordId ? ' ⬅️【新记录】' : '';
|
||||
console.log(` [${i + 1}] ${r.category} | ¥${r.amount} | date=${date.toLocaleString('zh-CN')} | createdAt=${created.toLocaleString('zh-CN')}${isNew}`);
|
||||
});
|
||||
|
||||
// 检查新记录位置
|
||||
const newIndex = allRecords.findIndex(r => r.id === newRecordId);
|
||||
console.log(`\n🔍 排序位置分析:`);
|
||||
console.log(` 新记录位置: 第 ${newIndex + 1} 位 (共 ${allRecords.length} 条)`);
|
||||
console.log(` 预期位置: 第 1 位`);
|
||||
|
||||
if (newIndex === 0) {
|
||||
console.log(` ✅ 新记录在第一位 (后端排序符合预期)`);
|
||||
} else {
|
||||
console.log(` ❌ 【BUG确认】新记录不在第一位!`);
|
||||
console.log(` 根因分析:`);
|
||||
|
||||
const firstRecord = allRecords[0];
|
||||
console.log(` - 第1条记录 date: ${new Date(firstRecord.date).toLocaleString('zh-CN')}`);
|
||||
console.log(` - 新记录 date: ${new Date(allRecords[newIndex].date).toLocaleString('zh-CN')}`);
|
||||
console.log(` - 后端按 date 字段降序排序`);
|
||||
console.log(` - 新记录的date时间部分为 00:00/08:00`);
|
||||
console.log(` - 当天已有记录的date时间部分更接近当前时间`);
|
||||
console.log(` - 所以新记录排在后面`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 测试4: 前端表单日期类型分析
|
||||
// ==========================================
|
||||
console.log(`\n\n📋 【测试4】前端日期输入框类型分析`);
|
||||
console.log('-'.repeat(60));
|
||||
console.log(`📌 前端代码分析 (Record/index.tsx):`);
|
||||
console.log(` 行14: date: new Date().toISOString().split('T')[0]`);
|
||||
console.log(` 行298-304: <input type="date" id="date" ... />`);
|
||||
console.log(` 行60: date: new Date().toISOString().split('T')[0] (重置表单)`);
|
||||
console.log(`\n🔍 问题分析:`);
|
||||
console.log(` input type: "date" (不是 "datetime-local")`);
|
||||
console.log(` 默认值格式: "${dateOnly}" (只有日期,无时间)`);
|
||||
console.log(` 传给后端: date 字段只有日期部分,时间部分为 00:00:00 UTC`);
|
||||
console.log(` 时区转换: UTC 00:00 → 北京时间 08:00`);
|
||||
console.log(`\n🔴 【根因确认】`);
|
||||
console.log(` 使用 type="date" 只保存日期不保存时间`);
|
||||
console.log(` 同一天的多条记录,date字段时间部分都是 08:00`);
|
||||
console.log(` 后端按 date 降序排序,同一天内记录顺序不确定`);
|
||||
console.log(` 新添加的记录不会排在最前面`);
|
||||
|
||||
// ==========================================
|
||||
// 测试5: 首页数据同步验证
|
||||
// ==========================================
|
||||
console.log(`\n\n📋 【测试5】首页 Dashboard 数据同步验证`);
|
||||
console.log('-'.repeat(60));
|
||||
console.log(`📌 前端代码分析:`);
|
||||
console.log(` dataStore.ts 行90-101 (createRecord):`);
|
||||
console.log(` await recordsApi.createRecord(data);`);
|
||||
console.log(` await get().fetchRecords(); ✅ 调用`);
|
||||
console.log(` await get().fetchDashboardSummary(); ✅ 调用`);
|
||||
console.log(`\n Dashboard/index.tsx 行67-69 (最近记录):`);
|
||||
console.log(` const recentRecords = records`);
|
||||
console.log(` .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())`);
|
||||
console.log(` .slice(0, 5);`);
|
||||
console.log(`\n🔍 问题分析:`);
|
||||
console.log(` 1. createRecord 后确实调用了 fetchRecords() ✅`);
|
||||
console.log(` 2. Dashboard 使用了 useDataStore() 响应式订阅 ✅`);
|
||||
console.log(` 3. 但前端对 records 再次按 date 排序 ❌`);
|
||||
console.log(` 4. 同样的问题:按 date 排序,新记录时间部分为 08:00`);
|
||||
console.log(` 5. 首页"最近记录"也不会显示新记录在最前面`);
|
||||
|
||||
// ==========================================
|
||||
// 测试6: 前端排序逻辑对比
|
||||
// ==========================================
|
||||
console.log(`\n\n📋 【测试6】前后端排序逻辑对比`);
|
||||
console.log('-'.repeat(60));
|
||||
console.log(`📌 后端排序 (index.js 行188-192):`);
|
||||
console.log(` orderBy: { date: 'desc' }`);
|
||||
console.log(`\n📌 前端 Record 页面排序 (Record/index.tsx 行28-32):`);
|
||||
console.log(` .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())`);
|
||||
console.log(`\n📌 前端 Dashboard 排序 (Dashboard/index.tsx 行67-69):`);
|
||||
console.log(` .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())`);
|
||||
console.log(`\n🔍 结论:`);
|
||||
console.log(` 前后端都使用 date 字段降序排序`);
|
||||
console.log(` 当 date 时间部分相同时,排序结果不稳定`);
|
||||
console.log(` 建议改为按 createdAt 排序,保证新添加记录排在最前`);
|
||||
|
||||
// ==========================================
|
||||
// 清理测试数据
|
||||
// ==========================================
|
||||
if (newRecordId) {
|
||||
console.log(`\n\n📋 【清理测试数据】`);
|
||||
console.log('-'.repeat(60));
|
||||
const deleteResult = await apiRequest('DELETE', `/api/records/${newRecordId}`);
|
||||
console.log(` 删除记录 ID: ${newRecordId}`);
|
||||
console.log(` 结果: ${deleteResult.status === 200 ? '✅ 成功' : '❌ 失败'}`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 测试总结
|
||||
// ==========================================
|
||||
logSeparator();
|
||||
console.log('📊 测试总结');
|
||||
logSeparator();
|
||||
console.log(`
|
||||
【问题1】新记录不在账单明细第一个位置
|
||||
状态: ❌ 确认存在
|
||||
严重程度: 高
|
||||
根因: 后端按 date 字段降序排序,新记录date时间部分为00:00/08:00
|
||||
|
||||
【问题2】首页"最近记录"没有显示新记录
|
||||
状态: ❌ 确认存在
|
||||
严重程度: 高
|
||||
根因: 前端同样按 date 字段排序,与后端问题一致
|
||||
|
||||
【根本原因分析】
|
||||
1. 前端使用 type="date" 只保存日期,不保存时间
|
||||
2. 后端按 date 字段降序排序(而非 createdAt)
|
||||
3. 同一天的记录,date 时间部分相同(均为08:00)
|
||||
4. SQLite 对相同值的排序结果不稳定
|
||||
|
||||
【修复方案对比】
|
||||
方案A: 后端排序改为 orderBy: { createdAt: 'desc' }
|
||||
优点: 简单直接,新记录永远排在最前面
|
||||
缺点: 编辑旧记录时,会跳到最前面(但这是合理行为)
|
||||
推荐指数: ★★★★★
|
||||
|
||||
方案B: 前端改为 type="datetime-local"
|
||||
优点: 保留完整时间信息
|
||||
缺点: 用户体验差(需要选择具体时间),不符合记账习惯
|
||||
推荐指数: ★★
|
||||
|
||||
推荐方案: A(修改排序字段为 createdAt)
|
||||
|
||||
【修复涉及文件】
|
||||
1. backend/src/index.js 第190行
|
||||
修改: orderBy: { date: 'desc' } → orderBy: { createdAt: 'desc' }
|
||||
|
||||
2. frontend/src/pages/Record/index.tsx 第32行
|
||||
修改: .sort((a, b) => new Date(b.date)... → .sort((a, b) => new Date(b.createdAt)...)
|
||||
|
||||
3. frontend/src/pages/Dashboard/index.tsx 第68行
|
||||
修改: .sort((a, b) => new Date(b.date)... → .sort((a, b) => new Date(b.createdAt)...)
|
||||
`);
|
||||
}
|
||||
|
||||
runTests().catch(console.error);
|
||||
@@ -0,0 +1,158 @@
|
||||
// Simple test script - use fetch
|
||||
import http from 'http';
|
||||
|
||||
async function testApi() {
|
||||
console.log('🧪 开始测试API...');
|
||||
|
||||
// 1. 测试健康检查
|
||||
console.log('\n1️⃣ 测试健康检查...');
|
||||
const healthResult = await makeRequest('/health', 'GET');
|
||||
console.log(healthResult);
|
||||
|
||||
// 2. 创建测试用户
|
||||
console.log('\n2️⃣ 创建测试用户...');
|
||||
const userResult = await makeRequest('/api/users', 'POST', {
|
||||
name: '测试用户',
|
||||
email: 'test@example.com'
|
||||
});
|
||||
console.log('用户创建结果:', userResult);
|
||||
|
||||
const userId = userResult.data?.id || 1;
|
||||
console.log(`使用 User ID: ${userId}`);
|
||||
|
||||
// 3. 创建账户
|
||||
console.log('\n3️⃣ 创建测试账户...');
|
||||
const account1 = await makeRequest('/api/accounts', 'POST', {
|
||||
userId,
|
||||
name: '支付宝',
|
||||
type: 'payment',
|
||||
color: '#1890FF',
|
||||
balance: 5000
|
||||
});
|
||||
console.log('账户1:', account1);
|
||||
|
||||
const account2 = await makeRequest('/api/accounts', 'POST', {
|
||||
userId,
|
||||
name: '微信钱包',
|
||||
type: 'payment',
|
||||
color: '#52C41A',
|
||||
balance: 3000
|
||||
});
|
||||
console.log('账户2:', account2);
|
||||
|
||||
// 4. 创建记录
|
||||
console.log('\n4️⃣ 创建交易记录...');
|
||||
const today = new Date();
|
||||
await makeRequest('/api/records', 'POST', {
|
||||
userId,
|
||||
accountId: account1.data?.id || 1,
|
||||
type: 'income',
|
||||
amount: 8500,
|
||||
category: '工资',
|
||||
description: '2026年4月工资',
|
||||
date: new Date(today.getFullYear(), today.getMonth(), 1).toISOString()
|
||||
});
|
||||
|
||||
await makeRequest('/api/records', 'POST', {
|
||||
userId,
|
||||
accountId: account1.data?.id || 1,
|
||||
type: 'expense',
|
||||
amount: 68,
|
||||
category: '餐饮',
|
||||
description: '午饭',
|
||||
date: today.toISOString()
|
||||
});
|
||||
|
||||
await makeRequest('/api/records', 'POST', {
|
||||
userId,
|
||||
accountId: account2.data?.id || 2,
|
||||
type: 'expense',
|
||||
amount: 25,
|
||||
category: '交通',
|
||||
description: '打车',
|
||||
date: today.toISOString()
|
||||
});
|
||||
|
||||
// 5. 创建预算
|
||||
console.log('\n5️⃣ 创建预算...');
|
||||
const month = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`;
|
||||
await makeRequest('/api/budgets', 'POST', {
|
||||
userId,
|
||||
category: '餐饮',
|
||||
amount: 1500,
|
||||
month
|
||||
});
|
||||
await makeRequest('/api/budgets', 'POST', {
|
||||
userId,
|
||||
category: '交通',
|
||||
amount: 500,
|
||||
month
|
||||
});
|
||||
await makeRequest('/api/budgets', 'POST', {
|
||||
userId,
|
||||
category: '购物',
|
||||
amount: 1000,
|
||||
month
|
||||
});
|
||||
await makeRequest('/api/budgets', 'POST', {
|
||||
userId,
|
||||
category: '娱乐',
|
||||
amount: 500,
|
||||
month
|
||||
});
|
||||
|
||||
// 6. 测试仪表盘接口
|
||||
console.log('\n6️⃣ 测试仪表盘接口...');
|
||||
const dashboard = await makeRequest(`/api/dashboard/summary?userId=${userId}`, 'GET');
|
||||
console.log('仪表盘数据:', dashboard);
|
||||
|
||||
console.log('\n✅ 所有API测试完成!');
|
||||
}
|
||||
|
||||
function makeRequest(path, method = 'GET', data = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = data ? JSON.stringify(data) : null;
|
||||
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3001,
|
||||
path,
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (postData) {
|
||||
options.headers['Content-Length'] = Buffer.byteLength(postData);
|
||||
}
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let body = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch {
|
||||
resolve(body);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
if (postData) {
|
||||
req.write(postData);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
testApi().catch(console.error);
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 回归测试脚本:账单倒序排序功能完整验证
|
||||
*
|
||||
* 测试场景:
|
||||
* 1. 添加5条支出记录(餐饮、交通、购物、娱乐、医疗),每条间隔1秒
|
||||
* 2. 添加5条收入记录(工资、奖金、投资、兼职、理财),每条间隔1秒
|
||||
* 3. 验证首页最近5条记录按createdAt倒序
|
||||
* 4. 验证记账页面所有记录按createdAt倒序
|
||||
* 5. 验证筛选后仍保持倒序
|
||||
* 6. 同一秒内添加2条记录,验证排序稳定性
|
||||
* 7. 删除中间记录后验证剩余记录排序
|
||||
* 8. 跨天记录验证排序
|
||||
*
|
||||
* 测试环境:
|
||||
* - 后端: http://localhost:3001
|
||||
* - 用户ID: 1
|
||||
* - 账户ID: 3 (招商银行)
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const BASE_URL = 'http://localhost:3001';
|
||||
const USER_ID = 1;
|
||||
const ACCOUNT_ID = 3;
|
||||
const SCREENSHOT_DIR = 'd:\\Users\\kaifa\\Trae_cn260425\\test-screenshots\\sort-fix-regression';
|
||||
|
||||
// 测试结果收集
|
||||
const testResults = [];
|
||||
let testCounter = 0;
|
||||
|
||||
function recordTest(name, status, details = '') {
|
||||
testCounter++;
|
||||
testResults.push({
|
||||
id: testCounter,
|
||||
name,
|
||||
status,
|
||||
details,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
|
||||
console.log(` ${icon} [TC-${testCounter}] ${name}: ${status}${details ? ' - ' + details : ''}`);
|
||||
}
|
||||
|
||||
function apiRequest(method, urlPath, body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(urlPath, BASE_URL);
|
||||
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, body: JSON.parse(data) });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, body: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function logSeparator(title) {
|
||||
console.log(`\n${'='.repeat(70)}`);
|
||||
if (title) console.log(` ${title}`);
|
||||
console.log('='.repeat(70));
|
||||
}
|
||||
|
||||
async function createRecord(type, category, amount, description, date) {
|
||||
const result = await apiRequest('POST', '/api/records', {
|
||||
userId: USER_ID,
|
||||
accountId: ACCOUNT_ID,
|
||||
type,
|
||||
amount,
|
||||
category,
|
||||
description,
|
||||
date: date || new Date().toISOString().split('T')[0]
|
||||
});
|
||||
return result.body.data;
|
||||
}
|
||||
|
||||
async function getRecords(filterType = null) {
|
||||
let url = `/api/records?userId=${USER_ID}`;
|
||||
if (filterType) url += `&type=${filterType}`;
|
||||
const result = await apiRequest('GET', url);
|
||||
return result.body.data || [];
|
||||
}
|
||||
|
||||
async function deleteRecord(id) {
|
||||
const result = await apiRequest('DELETE', `/api/records/${id}`);
|
||||
return result.status === 200;
|
||||
}
|
||||
|
||||
// ==================== 测试执行 ====================
|
||||
|
||||
async function runTests() {
|
||||
// 创建截图目录
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
logSeparator('🚀 账单倒序排序功能 - 完整回归测试');
|
||||
console.log(` 测试时间: ${new Date().toLocaleString('zh-CN')}`);
|
||||
console.log(` 测试环境: ${BASE_URL}`);
|
||||
console.log(` 用户ID: ${USER_ID} | 账户ID: ${ACCOUNT_ID}`);
|
||||
|
||||
// ---- 测试1: 添加5条支出记录 ----
|
||||
logSeparator('📝 测试1: 添加5条支出记录(每条间隔1秒)');
|
||||
|
||||
const expenseRecords = [
|
||||
{ category: '餐饮', amount: 35, description: '午餐外卖' },
|
||||
{ category: '交通', amount: 15, description: '地铁通勤' },
|
||||
{ category: '购物', amount: 199, description: '日用品采购' },
|
||||
{ category: '娱乐', amount: 68, description: '电影票' },
|
||||
{ category: '医疗', amount: 120, description: '感冒药' },
|
||||
];
|
||||
|
||||
const createdExpenseIds = [];
|
||||
for (const exp of expenseRecords) {
|
||||
const record = await createRecord('expense', exp.category, exp.amount, exp.description);
|
||||
createdExpenseIds.push(record.id);
|
||||
console.log(` ➕ 支出: ${exp.category} ¥${exp.amount} | ID:${record.id} | createdAt:${new Date(record.createdAt).toLocaleString('zh-CN')}`);
|
||||
await sleep(1100); // 间隔1.1秒确保createdAt不同
|
||||
}
|
||||
recordTest('添加5条支出记录', 'PASS', `IDs: ${createdExpenseIds.join(', ')}`);
|
||||
|
||||
// ---- 测试2: 添加5条收入记录 ----
|
||||
logSeparator('📝 测试2: 添加5条收入记录(每条间隔1秒)');
|
||||
|
||||
const incomeRecords = [
|
||||
{ category: '工资', amount: 12000, description: '4月工资' },
|
||||
{ category: '奖金', amount: 2000, description: '季度奖金' },
|
||||
{ category: '投资', amount: 500, description: '基金收益' },
|
||||
{ category: '兼职', amount: 800, description: '技术咨询' },
|
||||
{ category: '理财', amount: 300, description: '银行理财到期' },
|
||||
];
|
||||
|
||||
const createdIncomeIds = [];
|
||||
for (const inc of incomeRecords) {
|
||||
const record = await createRecord('income', inc.category, inc.amount, inc.description);
|
||||
createdIncomeIds.push(record.id);
|
||||
console.log(` ➕ 收入: ${inc.category} ¥${inc.amount} | ID:${record.id} | createdAt:${new Date(record.createdAt).toLocaleString('zh-CN')}`);
|
||||
await sleep(1100);
|
||||
}
|
||||
recordTest('添加5条收入记录', 'PASS', `IDs: ${createdIncomeIds.join(', ')}`);
|
||||
|
||||
// ---- 测试3: 验证API返回按createdAt倒序 ----
|
||||
logSeparator('🔍 测试3: 验证API返回按createdAt倒序');
|
||||
|
||||
const allRecords = await getRecords();
|
||||
console.log(` 📊 总记录数: ${allRecords.length}`);
|
||||
console.log(` 📋 前10条记录:`);
|
||||
allRecords.slice(0, 10).forEach((r, i) => {
|
||||
const isNew = createdExpenseIds.includes(r.id) || createdIncomeIds.includes(r.id) ? ' ⬅️ 新' : '';
|
||||
console.log(` [${i+1}] ${r.type==='income'?'收入':'支出'} ${r.category} ¥${r.amount} | createdAt: ${new Date(r.createdAt).toLocaleString('zh-CN')}${isNew}`);
|
||||
});
|
||||
|
||||
// 检查createdAt是否严格降序
|
||||
let sortedCorrectly = true;
|
||||
for (let i = 0; i < allRecords.length - 1; i++) {
|
||||
if (new Date(allRecords[i].createdAt).getTime() < new Date(allRecords[i+1].createdAt).getTime()) {
|
||||
sortedCorrectly = false;
|
||||
console.log(` ❌ 排序异常: [${i+1}] ${new Date(allRecords[i].createdAt).toISOString()} < [${i+2}] ${new Date(allRecords[i+1].createdAt).toISOString()}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
recordTest('API按createdAt倒序', sortedCorrectly ? 'PASS' : 'FAIL', `共${allRecords.length}条记录`);
|
||||
|
||||
// 验证最新创建的记录在第一位
|
||||
const lastCreatedId = createdIncomeIds[createdIncomeIds.length - 1]; // 最后一条收入
|
||||
const firstRecord = allRecords[0];
|
||||
recordTest('最新记录在API第一位', firstRecord.id === lastCreatedId ? 'PASS' : 'FAIL',
|
||||
`预期ID:${lastCreatedId}, 实际ID:${firstRecord.id}`);
|
||||
|
||||
// ---- 测试4: 验证首页最近5条记录 ----
|
||||
logSeparator('🔍 测试4: 验证首页最近5条记录(前5条)');
|
||||
|
||||
const recent5 = allRecords.slice(0, 5);
|
||||
console.log(` 📋 最近5条记录:`);
|
||||
recent5.forEach((r, i) => {
|
||||
console.log(` [${i+1}] ${r.type==='income'?'收入':'支出'} ${r.category} ¥${r.amount} | ${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`);
|
||||
});
|
||||
|
||||
// 验证前5条也是按createdAt降序
|
||||
let recent5Sorted = true;
|
||||
for (let i = 0; i < recent5.length - 1; i++) {
|
||||
if (new Date(recent5[i].createdAt).getTime() < new Date(recent5[i+1].createdAt).getTime()) {
|
||||
recent5Sorted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
recordTest('首页最近5条按createdAt倒序', recent5Sorted ? 'PASS' : 'FAIL');
|
||||
|
||||
// ---- 测试5: 支出筛选后排序验证 ----
|
||||
logSeparator('🔍 测试5: 支出筛选后排序验证');
|
||||
|
||||
const expenseFiltered = await getRecords('expense');
|
||||
console.log(` 📊 支出记录数: ${expenseFiltered.length}`);
|
||||
|
||||
let expenseSorted = true;
|
||||
for (let i = 0; i < expenseFiltered.length - 1; i++) {
|
||||
if (new Date(expenseFiltered[i].createdAt).getTime() < new Date(expenseFiltered[i+1].createdAt).getTime()) {
|
||||
expenseSorted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` 📋 前5条支出:`);
|
||||
expenseFiltered.slice(0, 5).forEach((r, i) => {
|
||||
console.log(` [${i+1}] ${r.category} ¥${r.amount} | ${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`);
|
||||
});
|
||||
recordTest('支出筛选后按createdAt倒序', expenseSorted ? 'PASS' : 'FAIL');
|
||||
|
||||
// ---- 测试6: 收入筛选后排序验证 ----
|
||||
logSeparator('🔍 测试6: 收入筛选后排序验证');
|
||||
|
||||
const incomeFiltered = await getRecords('income');
|
||||
console.log(` 📊 收入记录数: ${incomeFiltered.length}`);
|
||||
|
||||
let incomeSorted = true;
|
||||
for (let i = 0; i < incomeFiltered.length - 1; i++) {
|
||||
if (new Date(incomeFiltered[i].createdAt).getTime() < new Date(incomeFiltered[i+1].createdAt).getTime()) {
|
||||
incomeSorted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` 📋 前5条收入:`);
|
||||
incomeFiltered.slice(0, 5).forEach((r, i) => {
|
||||
console.log(` [${i+1}] ${r.category} ¥${r.amount} | ${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`);
|
||||
});
|
||||
recordTest('收入筛选后按createdAt倒序', incomeSorted ? 'PASS' : 'FAIL');
|
||||
|
||||
// ---- 测试7: 同一秒内添加2条记录 ----
|
||||
logSeparator('🔍 测试7: 同一秒内添加2条记录(排序稳定性)');
|
||||
|
||||
const sameTime1 = await createRecord('expense', '餐饮', 10, '同时记录1');
|
||||
const sameTime2 = await createRecord('expense', '交通', 20, '同时记录2');
|
||||
console.log(` ➕ 记录1: ID:${sameTime1.id} | createdAt:${new Date(sameTime1.createdAt).toISOString()}`);
|
||||
console.log(` ➕ 记录2: ID:${sameTime2.id} | createdAt:${new Date(sameTime2.createdAt).toISOString()}`);
|
||||
|
||||
const recordsAfterSameTime = await getRecords('expense');
|
||||
const idx1 = recordsAfterSameTime.findIndex(r => r.id === sameTime1.id);
|
||||
const idx2 = recordsAfterSameTime.findIndex(r => r.id === sameTime2.id);
|
||||
console.log(` 记录1位置: 第${idx1+1}位 | 记录2位置: 第${idx2+1}位`);
|
||||
|
||||
// SQLite的createdAt由数据库自动生成,即使同一秒也应该有微小差异或保持插入顺序
|
||||
// 只要不出现排序混乱即可
|
||||
recordTest('同秒记录排序稳定性', 'PASS', `记录1位置:${idx1+1}, 记录2位置:${idx2+1} (同秒允许顺序不定)`);
|
||||
|
||||
// ---- 测试8: 删除中间记录后排序验证 ----
|
||||
logSeparator('🔍 测试8: 删除中间记录后排序验证');
|
||||
|
||||
// 删除第3条创建的支出记录(购物)
|
||||
const deleteTargetId = createdExpenseIds[2]; // 购物记录
|
||||
const beforeDelete = await getRecords();
|
||||
const deleteTargetIndex = beforeDelete.findIndex(r => r.id === deleteTargetId);
|
||||
console.log(` 🗑️ 删除记录: ID:${deleteTargetId} (${beforeDelete[deleteTargetIndex]?.category} ¥${beforeDelete[deleteTargetIndex]?.amount})`);
|
||||
|
||||
const deleteSuccess = await deleteRecord(deleteTargetId);
|
||||
recordTest('删除记录', deleteSuccess ? 'PASS' : 'FAIL', `ID:${deleteTargetId}`);
|
||||
|
||||
const afterDelete = await getRecords();
|
||||
let afterDeleteSorted = true;
|
||||
for (let i = 0; i < afterDelete.length - 1; i++) {
|
||||
if (new Date(afterDelete[i].createdAt).getTime() < new Date(afterDelete[i+1].createdAt).getTime()) {
|
||||
afterDeleteSorted = false;
|
||||
console.log(` ❌ 排序异常: [${i+1}] < [${i+2}]`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log(` 📊 删除后记录数: ${afterDelete.length} (原${beforeDelete.length}条)`);
|
||||
recordTest('删除后剩余记录排序正确', afterDeleteSorted ? 'PASS' : 'FAIL');
|
||||
|
||||
// ---- 测试9: 跨天记录排序验证 ----
|
||||
logSeparator('🔍 测试9: 跨天记录排序验证');
|
||||
|
||||
// 创建昨天的记录
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
const yesterdayRecord = await createRecord('expense', '餐饮', 50, '昨天晚餐', yesterdayStr);
|
||||
console.log(` ➕ 昨天记录: ${yesterdayRecord.category} ¥${yesterdayRecord.amount} | date:${yesterdayStr} | createdAt:${new Date(yesterdayRecord.createdAt).toLocaleString('zh-CN')}`);
|
||||
|
||||
const recordsWithYesterday = await getRecords();
|
||||
const yesterdayIdx = recordsWithYesterday.findIndex(r => r.id === yesterdayRecord.id);
|
||||
console.log(` 昨天记录位置: 第${yesterdayIdx+1}位 (共${recordsWithYesterday.length}条)`);
|
||||
|
||||
// 昨天的记录应该排在今天的记录后面
|
||||
const todayRecords = recordsWithYesterday.filter(r => {
|
||||
const createdDate = new Date(r.createdAt).toLocaleDateString('zh-CN');
|
||||
const todayDate = new Date().toLocaleDateString('zh-CN');
|
||||
return createdDate === todayDate;
|
||||
});
|
||||
|
||||
const yesterdayRecordsAfter = recordsWithYesterday.filter(r => {
|
||||
const createdDate = new Date(r.createdAt).toLocaleDateString('zh-CN');
|
||||
const todayDate = new Date().toLocaleDateString('zh-CN');
|
||||
return createdDate !== todayDate;
|
||||
});
|
||||
|
||||
let crossDaySorted = true;
|
||||
// 验证所有今天的记录都在昨天的记录前面
|
||||
if (todayRecords.length > 0 && yesterdayRecordsAfter.length > 0) {
|
||||
const lastToday = new Date(todayRecords[todayRecords.length - 1].createdAt).getTime();
|
||||
const firstYesterday = new Date(yesterdayRecordsAfter[0].createdAt).getTime();
|
||||
if (lastToday < firstYesterday) {
|
||||
crossDaySorted = false;
|
||||
}
|
||||
}
|
||||
recordTest('跨天记录排序正确', crossDaySorted ? 'PASS' : 'FAIL',
|
||||
`今天:${todayRecords.length}条 | 昨天:${yesterdayRecordsAfter.length}条`);
|
||||
|
||||
// ---- 清理测试数据 ----
|
||||
logSeparator('🧹 清理测试数据');
|
||||
|
||||
const allCreatedIds = [...createdExpenseIds.slice(0, 2), ...createdExpenseIds.slice(3), ...createdIncomeIds, sameTime1.id, sameTime2.id, yesterdayRecord.id];
|
||||
// 注意: createdExpenseIds[2] (购物) 已经删除了
|
||||
|
||||
for (const id of allCreatedIds) {
|
||||
await deleteRecord(id);
|
||||
}
|
||||
console.log(` ✅ 已清理 ${allCreatedIds.length} 条测试记录`);
|
||||
|
||||
// ---- 生成测试报告 ----
|
||||
logSeparator('📊 测试报告');
|
||||
|
||||
const passCount = testResults.filter(r => r.status === 'PASS').length;
|
||||
const failCount = testResults.filter(r => r.status === 'FAIL').length;
|
||||
const totalCount = testResults.length;
|
||||
|
||||
console.log(`\n 总测试用例: ${totalCount}`);
|
||||
console.log(` ✅ 通过: ${passCount}`);
|
||||
console.log(` ❌ 失败: ${failCount}`);
|
||||
console.log(` 通过率: ${((passCount / totalCount) * 100).toFixed(1)}%`);
|
||||
|
||||
console.log(`\n 详细结果:`);
|
||||
testResults.forEach(r => {
|
||||
const icon = r.status === 'PASS' ? '✅' : '❌';
|
||||
console.log(` ${icon} [TC-${r.id}] ${r.name}${r.details ? ': ' + r.details : ''}`);
|
||||
});
|
||||
|
||||
// 保存测试报告到文件
|
||||
const reportPath = path.join(SCREENSHOT_DIR, 'test-report.json');
|
||||
const reportData = {
|
||||
testDate: new Date().toISOString(),
|
||||
environment: {
|
||||
backendUrl: BASE_URL,
|
||||
userId: USER_ID,
|
||||
accountId: ACCOUNT_ID
|
||||
},
|
||||
summary: {
|
||||
total: totalCount,
|
||||
passed: passCount,
|
||||
failed: failCount,
|
||||
passRate: `${((passCount / totalCount) * 100).toFixed(1)}%`
|
||||
},
|
||||
testCases: testResults
|
||||
};
|
||||
|
||||
fs.writeFileSync(reportPath, JSON.stringify(reportData, null, 2));
|
||||
console.log(`\n 📁 测试报告已保存: ${reportPath}`);
|
||||
|
||||
if (failCount > 0) {
|
||||
console.log(`\n ⚠️ 【高危】存在${failCount}个失败用例,建议修复后重新测试`);
|
||||
} else {
|
||||
console.log(`\n 🎉 所有测试用例通过!账单倒序排序功能修复验证成功`);
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch(err => {
|
||||
console.error('测试执行失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* =============================================
|
||||
* 时间显示修复 - 完整回归测试脚本
|
||||
* =============================================
|
||||
* 测试目标: 验证 createdAt 排序修复和 parseDate 本地时区解析
|
||||
* 测试环境: 后端 API (localhost:3001)
|
||||
* 测试日期: 2026-04-26
|
||||
* =============================================
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const BASE_URL = 'http://localhost:3001';
|
||||
const USER_ID = 1;
|
||||
|
||||
// 测试报告
|
||||
const report = {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
warnings: 0,
|
||||
results: [],
|
||||
startTime: new Date(),
|
||||
endTime: null
|
||||
};
|
||||
|
||||
function log(msg, level = 'INFO') {
|
||||
const icon = { INFO: '📝', PASS: '✅', FAIL: '❌', WARN: '⚠️', HEADER: '📋', SEP: '─' };
|
||||
const prefix = icon[level] || '📝';
|
||||
console.log(`${prefix} ${msg}`);
|
||||
}
|
||||
|
||||
function recordTest(name, status, detail = '') {
|
||||
report.total++;
|
||||
if (status === 'PASS') {
|
||||
report.passed++;
|
||||
log(`${name} - PASS${detail ? ' | ' + detail : ''}`, 'PASS');
|
||||
} else if (status === 'WARN') {
|
||||
report.warnings++;
|
||||
log(`${name} - WARN${detail ? ' | ' + detail : ''}`, 'WARN');
|
||||
} else {
|
||||
report.failed++;
|
||||
log(`${name} - FAIL${detail ? ' | ' + detail : ''}`, 'FAIL');
|
||||
}
|
||||
report.results.push({ name, status, detail });
|
||||
}
|
||||
|
||||
// API 请求封装
|
||||
function apiRequest(method, path, body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(path, BASE_URL);
|
||||
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({ statusCode: res.statusCode, body: JSON.parse(data) });
|
||||
} catch (e) {
|
||||
resolve({ statusCode: res.statusCode, body: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (body) req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 创建记录辅助函数
|
||||
async function createRecord(type, category, amount, description, date = null) {
|
||||
const payload = {
|
||||
userId: USER_ID,
|
||||
accountId: 3,
|
||||
type,
|
||||
amount,
|
||||
category,
|
||||
description,
|
||||
date: date || new Date().toISOString().split('T')[0]
|
||||
};
|
||||
const result = await apiRequest('POST', '/api/records', payload);
|
||||
if (result.body.success) {
|
||||
return result.body.data;
|
||||
} else {
|
||||
throw new Error(`创建记录失败: ${result.body.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取记录辅助函数
|
||||
async function getRecords(params = {}) {
|
||||
const query = new URLSearchParams({ userId: USER_ID, ...params });
|
||||
const result = await apiRequest('GET', `/api/records?${query}`);
|
||||
if (result.body.success) {
|
||||
return result.body.data;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 删除记录辅助函数
|
||||
async function deleteRecord(id) {
|
||||
await apiRequest('DELETE', `/api/records/${id}`);
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 主测试流程
|
||||
// =============================================
|
||||
async function runTests() {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('🧪 时间显示修复 - 完整回归测试');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`测试开始时间: ${new Date().toLocaleString('zh-CN')}`);
|
||||
console.log(`后端地址: ${BASE_URL}`);
|
||||
console.log(`测试用户: userId=${USER_ID}`);
|
||||
console.log('='.repeat(60) + '\n');
|
||||
|
||||
// ==========================================
|
||||
// 测试1: 验证后端 parseDate 函数
|
||||
// ==========================================
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
log('【测试1】验证后端 parseDate 函数对纯日期字符串的解析', 'HEADER');
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
|
||||
// 创建一条记录,使用纯日期字符串
|
||||
const now = new Date();
|
||||
const dateStr = '2026-04-26';
|
||||
const record1 = await createRecord('expense', '餐饮', 10, 'parseDate测试', dateStr);
|
||||
|
||||
const parsedDate = new Date(record1.date);
|
||||
const expectedHour = 0; // 应该解析为当天 00:00 北京时间
|
||||
const actualHour = parsedDate.getHours();
|
||||
|
||||
log(`输入日期字符串: "${dateStr}"`, 'INFO');
|
||||
log(`解析结果: ${parsedDate.toLocaleString('zh-CN')}`, 'INFO');
|
||||
log(`解析结果 ISO: ${parsedDate.toISOString()}`, 'INFO');
|
||||
log(`本地时区小时: ${actualHour}:00`, 'INFO');
|
||||
|
||||
if (actualHour === expectedHour) {
|
||||
recordTest('parseDate 解析纯日期为本地时区 00:00', 'PASS',
|
||||
`解析为 ${parsedDate.toLocaleString('zh-CN')} (北京时间 00:00,非 UTC 00:00)`);
|
||||
} else {
|
||||
recordTest('parseDate 解析纯日期为本地时区 00:00', 'FAIL',
|
||||
`期望 00:00,实际 ${actualHour}:00`);
|
||||
}
|
||||
|
||||
// 验证 createdAt 为实际创建时间
|
||||
const createdAt = new Date(record1.createdAt);
|
||||
const createdAtDiff = Math.abs(createdAt.getTime() - now.getTime());
|
||||
|
||||
log(`记录创建时间 (createdAt): ${createdAt.toLocaleString('zh-CN')}`, 'INFO');
|
||||
log(`创建时间差: ${createdAtDiff}ms`, 'INFO');
|
||||
|
||||
if (createdAtDiff < 5000) {
|
||||
recordTest('createdAt 反映实际创建时间', 'PASS',
|
||||
`与当前时间差 ${createdAtDiff}ms < 5s`);
|
||||
} else {
|
||||
recordTest('createdAt 反映实际创建时间', 'FAIL',
|
||||
`与当前时间差 ${createdAtDiff}ms > 5s`);
|
||||
}
|
||||
|
||||
await deleteRecord(record1.id);
|
||||
|
||||
// ==========================================
|
||||
// 测试2: 验证后端 API 返回的 createdAt 字段
|
||||
// ==========================================
|
||||
log('\n═══════════════════════════════════════════════════════', 'HEADER');
|
||||
log('【测试2】验证后端 API 返回的 createdAt 字段是否正确', 'HEADER');
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
|
||||
const beforeCreate = new Date();
|
||||
await sleep(100);
|
||||
const record2 = await createRecord('expense', '交通', 20, 'createdAt验证');
|
||||
const afterCreate = new Date();
|
||||
|
||||
const apiCreatedAt = new Date(record2.createdAt);
|
||||
|
||||
log(`创建前时间: ${beforeCreate.toLocaleString('zh-CN')}`, 'INFO');
|
||||
log(`API返回 createdAt: ${apiCreatedAt.toLocaleString('zh-CN')}`, 'INFO');
|
||||
log(`创建后时间: ${afterCreate.toLocaleString('zh-CN')}`, 'INFO');
|
||||
|
||||
const isWithinRange = apiCreatedAt >= beforeCreate && apiCreatedAt <= afterCreate;
|
||||
|
||||
if (isWithinRange) {
|
||||
recordTest('API 返回的 createdAt 在创建时间范围内', 'PASS',
|
||||
`${apiCreatedAt.toLocaleString('zh-CN')} 在 [${beforeCreate.toLocaleString('zh-CN')}, ${afterCreate.toLocaleString('zh-CN')}] 内`);
|
||||
} else {
|
||||
recordTest('API 返回的 createdAt 在创建时间范围内', 'FAIL',
|
||||
`createdAt 不在预期时间范围内`);
|
||||
}
|
||||
|
||||
// 验证 createdAt 包含时分秒
|
||||
const hasTimeComponent = record2.createdAt.includes(':');
|
||||
if (hasTimeComponent) {
|
||||
recordTest('createdAt 包含时分秒信息', 'PASS', `值: ${record2.createdAt}`);
|
||||
} else {
|
||||
recordTest('createdAt 包含时分秒信息', 'FAIL', `值: ${record2.createdAt}`);
|
||||
}
|
||||
|
||||
await deleteRecord(record2.id);
|
||||
|
||||
// ==========================================
|
||||
// 测试3: 验证后端排序逻辑 (orderBy: { createdAt: 'desc' })
|
||||
// ==========================================
|
||||
log('\n═══════════════════════════════════════════════════════', 'HEADER');
|
||||
log('【测试3】验证后端排序逻辑 (orderBy: createdAt desc)', 'HEADER');
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
|
||||
// 清空现有记录,确保测试环境干净
|
||||
const existingRecords = await getRecords();
|
||||
for (const r of existingRecords) {
|
||||
await deleteRecord(r.id);
|
||||
}
|
||||
log(`已清理 ${existingRecords.length} 条现有记录`, 'INFO');
|
||||
|
||||
// 创建3条同一天但不同时间的记录
|
||||
const records = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await createRecord('expense', '餐饮', 10 + i, `同天记录${i + 1}`);
|
||||
records.push(r);
|
||||
log(`创建记录 ${i + 1}: ID=${r.id}, createdAt=${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`, 'INFO');
|
||||
await sleep(500); // 确保 createdAt 有差异
|
||||
}
|
||||
|
||||
// 获取排序后的记录
|
||||
const sortedRecords = await getRecords();
|
||||
|
||||
log(`\n后端返回的排序顺序:`, 'INFO');
|
||||
sortedRecords.forEach((r, i) => {
|
||||
log(` [${i + 1}] ${r.category} ¥${r.amount} ${r.description} | createdAt=${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`, 'INFO');
|
||||
});
|
||||
|
||||
// 验证最新的记录在最前面
|
||||
const firstRecord = sortedRecords[0];
|
||||
const lastRecord = sortedRecords[sortedRecords.length - 1];
|
||||
|
||||
if (firstRecord && firstRecord.description === '同天记录3') {
|
||||
recordTest('最新创建的记录排在第一位', 'PASS', `ID=${firstRecord.id}`);
|
||||
} else {
|
||||
recordTest('最新创建的记录排在第一位', 'FAIL',
|
||||
`期望"同天记录3",实际"${firstRecord?.description}"`);
|
||||
}
|
||||
|
||||
// 验证排序是严格降序
|
||||
let isStrictDescending = true;
|
||||
for (let i = 0; i < sortedRecords.length - 1; i++) {
|
||||
const t1 = new Date(sortedRecords[i].createdAt).getTime();
|
||||
const t2 = new Date(sortedRecords[i + 1].createdAt).getTime();
|
||||
if (t1 < t2) {
|
||||
isStrictDescending = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStrictDescending) {
|
||||
recordTest('后端排序为严格降序', 'PASS', `${sortedRecords.length} 条记录排序正确`);
|
||||
} else {
|
||||
recordTest('后端排序为严格降序', 'FAIL', '排序出现异常');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 测试4: 验证前端 Dashboard 排序逻辑
|
||||
// ==========================================
|
||||
log('\n═══════════════════════════════════════════════════════', 'HEADER');
|
||||
log('【测试4】验证前端 Dashboard 使用 createdAt 排序', 'HEADER');
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
|
||||
// 读取前端代码验证排序逻辑
|
||||
const dashboardPath = path.join(__dirname, '..', 'frontend', 'src', 'pages', 'Dashboard', 'index.tsx');
|
||||
|
||||
if (fs.existsSync(dashboardPath)) {
|
||||
const dashboardCode = fs.readFileSync(dashboardPath, 'utf-8');
|
||||
|
||||
// 检查是否使用 createdAt 排序
|
||||
const hasCreatedAtSort = dashboardCode.includes('.sort((a, b) => new Date(b.createdAt)');
|
||||
const hasDateSort = dashboardCode.includes('.sort((a, b) => new Date(b.date)') && !hasCreatedAtSort;
|
||||
|
||||
if (hasCreatedAtSort) {
|
||||
recordTest('Dashboard 使用 createdAt 排序', 'PASS', '代码使用 new Date(b.createdAt) 排序');
|
||||
} else if (hasDateSort) {
|
||||
recordTest('Dashboard 使用 createdAt 排序', 'FAIL', '代码仍使用 new Date(b.date) 排序');
|
||||
} else {
|
||||
recordTest('Dashboard 使用 createdAt 排序', 'WARN', '未能识别排序逻辑,请手动确认');
|
||||
}
|
||||
|
||||
// 检查 formatRecordTime 函数
|
||||
const hasFormatRecordTime = dashboardCode.includes('const formatRecordTime');
|
||||
const usesCreatedAtForTime = dashboardCode.includes('new Date(record.createdAt)');
|
||||
|
||||
if (hasFormatRecordTime && usesCreatedAtForTime) {
|
||||
recordTest('Dashboard 时间格式化使用 createdAt', 'PASS', 'formatRecordTime 使用 record.createdAt');
|
||||
} else {
|
||||
recordTest('Dashboard 时间格式化使用 createdAt', 'FAIL', '时间格式化未使用 createdAt');
|
||||
}
|
||||
|
||||
// 检查时间显示逻辑
|
||||
const showsHHMMForToday = dashboardCode.includes("createdAt.getHours()") && dashboardCode.includes("createdAt.getMinutes()");
|
||||
if (showsHHMMForToday) {
|
||||
recordTest('Dashboard 今天显示 HH:MM 格式', 'PASS', '使用 getHours() 和 getMinutes() 格式化');
|
||||
} else {
|
||||
recordTest('Dashboard 今天显示 HH:MM 格式', 'FAIL', '未找到 HH:MM 格式化逻辑');
|
||||
}
|
||||
|
||||
const showsYesterday = dashboardCode.includes("'昨天'") || dashboardCode.includes('"昨天"');
|
||||
if (showsYesterday) {
|
||||
recordTest('Dashboard 昨天显示"昨天"文本', 'PASS', '包含"昨天"显示逻辑');
|
||||
} else {
|
||||
recordTest('Dashboard 昨天显示"昨天"文本', 'WARN', '未找到"昨天"显示逻辑');
|
||||
}
|
||||
} else {
|
||||
recordTest('Dashboard 文件存在性', 'FAIL', `文件不存在: ${dashboardPath}`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 测试5: 验证前端 Record 页面排序逻辑
|
||||
// ==========================================
|
||||
log('\n═══════════════════════════════════════════════════════', 'HEADER');
|
||||
log('【测试5】验证前端 Record 页面使用 createdAt 排序', 'HEADER');
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
|
||||
const recordPath = path.join(__dirname, '..', 'frontend', 'src', 'pages', 'Record', 'index.tsx');
|
||||
|
||||
if (fs.existsSync(recordPath)) {
|
||||
const recordCode = fs.readFileSync(recordPath, 'utf-8');
|
||||
|
||||
// 检查排序逻辑
|
||||
const hasCreatedAtSort = recordCode.includes('.sort((a, b) => new Date(b.createdAt)');
|
||||
const hasDateSort = recordCode.includes('.sort((a, b) => new Date(b.date)') && !hasCreatedAtSort;
|
||||
|
||||
if (hasCreatedAtSort) {
|
||||
recordTest('Record 页面使用 createdAt 排序', 'PASS', '代码使用 new Date(b.createdAt) 排序');
|
||||
} else if (hasDateSort) {
|
||||
recordTest('Record 页面使用 createdAt 排序', 'FAIL', '代码仍使用 new Date(b.date) 排序');
|
||||
} else {
|
||||
recordTest('Record 页面使用 createdAt 排序', 'WARN', '未能识别排序逻辑');
|
||||
}
|
||||
|
||||
// 检查 formatTime 函数
|
||||
const hasFormatTime = recordCode.includes('const formatTime');
|
||||
const usesCreatedAtForTime = recordCode.includes('new Date(record.createdAt)');
|
||||
|
||||
if (hasFormatTime && usesCreatedAtForTime) {
|
||||
recordTest('Record 页面时间格式化使用 createdAt', 'PASS', 'formatTime 使用 record.createdAt');
|
||||
} else {
|
||||
recordTest('Record 页面时间格式化使用 createdAt', 'FAIL', '时间格式化未使用 createdAt');
|
||||
}
|
||||
|
||||
// 检查今天显示 HH:MM
|
||||
const showsHHMM = recordCode.includes("hour: '2-digit'") && recordCode.includes("minute: '2-digit'");
|
||||
if (showsHHMM) {
|
||||
recordTest('Record 页面今天显示 HH:MM 格式', 'PASS', '使用 toLocaleTimeString 格式化');
|
||||
} else {
|
||||
recordTest('Record 页面今天显示 HH:MM 格式', 'FAIL', '未找到 HH:MM 格式化逻辑');
|
||||
}
|
||||
|
||||
// 检查昨天显示
|
||||
const showsYesterday = recordCode.includes("'昨天'") || recordCode.includes('"昨天"');
|
||||
if (showsYesterday) {
|
||||
recordTest('Record 页面昨天显示"昨天"文本', 'PASS', '包含"昨天"显示逻辑');
|
||||
} else {
|
||||
recordTest('Record 页面昨天显示"昨天"文本', 'WARN', '未找到"昨天"显示逻辑');
|
||||
}
|
||||
|
||||
// 检查过滤后再排序
|
||||
const filterThenSort = recordCode.includes('.filter(') && recordCode.includes('.sort(');
|
||||
if (filterThenSort) {
|
||||
recordTest('Record 页面先过滤再排序', 'PASS', '排序在过滤后执行');
|
||||
} else {
|
||||
recordTest('Record 页面先过滤再排序', 'WARN', '过滤和排序顺序需确认');
|
||||
}
|
||||
} else {
|
||||
recordTest('Record 文件存在性', 'FAIL', `文件不存在: ${recordPath}`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 测试6: 验证同一天多条记录的排序
|
||||
// ==========================================
|
||||
log('\n═══════════════════════════════════════════════════════', 'HEADER');
|
||||
log('【测试6】验证同一天添加多条记录的排序稳定性', 'HEADER');
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
|
||||
// 当前记录应该已经是按 createdAt 降序(来自测试3)
|
||||
// 再添加2条记录,验证最新始终在最前
|
||||
|
||||
const extra1 = await createRecord('expense', '购物', 50, '额外记录1');
|
||||
log(`创建额外记录1: ID=${extra1.id}`, 'INFO');
|
||||
await sleep(500);
|
||||
|
||||
const extra2 = await createRecord('income', '兼职', 100, '额外记录2');
|
||||
log(`创建额外记录2: ID=${extra2.id}`, 'INFO');
|
||||
await sleep(500);
|
||||
|
||||
const recordsAfterExtra = await getRecords();
|
||||
|
||||
log(`\n添加额外记录后的排序:`, 'INFO');
|
||||
recordsAfterExtra.forEach((r, i) => {
|
||||
log(` [${i + 1}] ${r.type === 'income' ? '+' : '-'}¥${r.amount} ${r.category} ${r.description} | ${new Date(r.createdAt).toLocaleTimeString('zh-CN')}`, 'INFO');
|
||||
});
|
||||
|
||||
const latestRecord = recordsAfterExtra[0];
|
||||
if (latestRecord && latestRecord.id === extra2.id) {
|
||||
recordTest('最新添加的记录始终在最前面', 'PASS', `最新记录: "${latestRecord.description}"`);
|
||||
} else {
|
||||
recordTest('最新添加的记录始终在最前面', 'FAIL',
|
||||
`期望"额外记录2"在最前,实际"${latestRecord?.description}"`);
|
||||
}
|
||||
|
||||
// 验证支出筛选后的排序
|
||||
const expenseRecords = await getRecords({ type: 'expense' });
|
||||
let expenseSorted = true;
|
||||
for (let i = 0; i < expenseRecords.length - 1; i++) {
|
||||
const t1 = new Date(expenseRecords[i].createdAt).getTime();
|
||||
const t2 = new Date(expenseRecords[i + 1].createdAt).getTime();
|
||||
if (t1 < t2) {
|
||||
expenseSorted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (expenseSorted) {
|
||||
recordTest('支出筛选后仍保持 createdAt 降序', 'PASS', `${expenseRecords.length} 条支出记录排序正确`);
|
||||
} else {
|
||||
recordTest('支出筛选后仍保持 createdAt 降序', 'FAIL', '支出排序异常');
|
||||
}
|
||||
|
||||
// 验证收入筛选后的排序
|
||||
const incomeRecords = await getRecords({ type: 'income' });
|
||||
let incomeSorted = true;
|
||||
for (let i = 0; i < incomeRecords.length - 1; i++) {
|
||||
const t1 = new Date(incomeRecords[i].createdAt).getTime();
|
||||
const t2 = new Date(incomeRecords[i + 1].createdAt).getTime();
|
||||
if (t1 < t2) {
|
||||
incomeSorted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (incomeSorted) {
|
||||
recordTest('收入筛选后仍保持 createdAt 降序', 'PASS', `${incomeRecords.length} 条收入记录排序正确`);
|
||||
} else {
|
||||
recordTest('收入筛选后仍保持 createdAt 降序', 'FAIL', '收入排序异常');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 测试7: 验证前端时间显示逻辑 (模拟)
|
||||
// ==========================================
|
||||
log('\n═══════════════════════════════════════════════════════', 'HEADER');
|
||||
log('【测试7】验证前端时间显示格式 (模拟前端逻辑)', 'HEADER');
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
|
||||
// 模拟前端 formatRecordTime 逻辑 (Dashboard)
|
||||
function simulateDashboardFormatTime(record) {
|
||||
const createdAt = new Date(record.createdAt);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const recordDay = new Date(createdAt.getFullYear(), createdAt.getMonth(), createdAt.getDate()).getTime();
|
||||
const diffDays = Math.floor((today - recordDay) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return `${createdAt.getHours().toString().padStart(2, '0')}:${createdAt.getMinutes().toString().padStart(2, '0')}`;
|
||||
} else if (diffDays === 1) {
|
||||
return '昨天';
|
||||
} else if (diffDays <= 7) {
|
||||
return `${diffDays}天前`;
|
||||
} else {
|
||||
return `${createdAt.getMonth() + 1}月${createdAt.getDate()}日`;
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟前端 formatTime 逻辑 (Record)
|
||||
function simulateRecordFormatTime(record) {
|
||||
const createdAt = new Date(record.createdAt);
|
||||
const today = new Date();
|
||||
const todayStr = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toDateString();
|
||||
const recordDay = new Date(createdAt.getFullYear(), createdAt.getMonth(), createdAt.getDate()).toDateString();
|
||||
|
||||
if (todayStr === recordDay) {
|
||||
return createdAt.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate()).toDateString();
|
||||
if (yesterdayStr === recordDay) {
|
||||
return '昨天';
|
||||
}
|
||||
|
||||
return createdAt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// 使用现有记录测试时间显示
|
||||
const allRecords = await getRecords();
|
||||
|
||||
if (allRecords.length > 0) {
|
||||
const todayRecord = allRecords.find(r => {
|
||||
const created = new Date(r.createdAt);
|
||||
const today = new Date();
|
||||
return created.getFullYear() === today.getFullYear() &&
|
||||
created.getMonth() === today.getMonth() &&
|
||||
created.getDate() === today.getDate();
|
||||
});
|
||||
|
||||
if (todayRecord) {
|
||||
const dashTime = simulateDashboardFormatTime(todayRecord);
|
||||
const recordTime = simulateRecordFormatTime(todayRecord);
|
||||
|
||||
log(`今天创建的记录: "${todayRecord.description}"`, 'INFO');
|
||||
log(` Dashboard 显示: "${dashTime}"`, 'INFO');
|
||||
log(` Record 显示: "${recordTime}"`, 'INFO');
|
||||
|
||||
// 验证不是 08:00
|
||||
if (dashTime !== '08:00') {
|
||||
recordTest('Dashboard 今天记录不显示 08:00', 'PASS', `显示: "${dashTime}"`);
|
||||
} else {
|
||||
recordTest('Dashboard 今天记录不显示 08:00', 'FAIL', `仍显示 "08:00"`);
|
||||
}
|
||||
|
||||
if (recordTime !== '08:00') {
|
||||
recordTest('Record 页面今天记录不显示 08:00', 'PASS', `显示: "${recordTime}"`);
|
||||
} else {
|
||||
recordTest('Record 页面今天记录不显示 08:00', 'FAIL', `仍显示 "08:00"`);
|
||||
}
|
||||
|
||||
// 验证格式为 HH:MM
|
||||
const dashTimeRegex = /^\d{2}:\d{2}$/;
|
||||
if (dashTimeRegex.test(dashTime)) {
|
||||
recordTest('Dashboard 时间格式为 HH:MM', 'PASS', `格式正确: "${dashTime}"`);
|
||||
} else {
|
||||
recordTest('Dashboard 时间格式为 HH:MM', 'FAIL', `格式不正确: "${dashTime}"`);
|
||||
}
|
||||
} else {
|
||||
recordTest('今天记录时间显示验证', 'WARN', '当前没有今天的记录,跳过此测试');
|
||||
}
|
||||
} else {
|
||||
recordTest('时间显示验证', 'WARN', '无记录可验证');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 测试8: 清理测试数据
|
||||
// ==========================================
|
||||
log('\n═══════════════════════════════════════════════════════', 'HEADER');
|
||||
log('【测试8】清理测试数据', 'HEADER');
|
||||
log('═══════════════════════════════════════════════════════', 'HEADER');
|
||||
|
||||
const finalRecords = await getRecords();
|
||||
for (const r of finalRecords) {
|
||||
await deleteRecord(r.id);
|
||||
}
|
||||
log(`已清理 ${finalRecords.length} 条测试记录`, 'INFO');
|
||||
recordTest('测试数据清理完成', 'PASS', `清理 ${finalRecords.length} 条记录`);
|
||||
|
||||
// ==========================================
|
||||
// 生成测试报告
|
||||
// ==========================================
|
||||
report.endTime = new Date();
|
||||
const duration = report.endTime - report.startTime;
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 测试报告');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`测试总数: ${report.total}`);
|
||||
console.log(`通过: ${report.passed}`);
|
||||
console.log(`失败: ${report.failed}`);
|
||||
console.log(`警告: ${report.warnings}`);
|
||||
console.log(`通过率: ${((report.passed / report.total) * 100).toFixed(1)}%`);
|
||||
console.log(`耗时: ${duration}ms`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
console.log('\n📋 测试结果明细:');
|
||||
console.log('-'.repeat(60));
|
||||
report.results.forEach((r, i) => {
|
||||
const icon = r.status === 'PASS' ? '✅' : r.status === 'WARN' ? '⚠️' : '❌';
|
||||
console.log(` ${icon} [${i + 1}] ${r.name}${r.detail ? ' | ' + r.detail : ''}`);
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
if (report.failed === 0) {
|
||||
console.log('🎉 全部测试通过!时间显示修复验证成功!');
|
||||
} else {
|
||||
console.log(`❌ ${report.failed} 项测试失败,请检查修复!`);
|
||||
}
|
||||
console.log('='.repeat(60) + '\n');
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// 执行测试
|
||||
runTests().catch(err => {
|
||||
console.error('测试执行异常:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user