stock-portfolio/__tests__/transactions.test.ts
kennethcheng 0051f92b2b v1.0.5 (2026-04-13)
- 📛 证券名称增强:修复 INTC 等证券名称显示为空的问题 (BUG-101)
- 🔒 SELL超量校验:修复卖出数量超过持仓导致持仓变负的Bug (BUG-002)
- 🔄 撤销BUY成本还原:修复撤销买入时平均成本计算公式错误 (BUG-003)
- 💹 Decimal精度计算:持仓分析改用Prisma.Decimal进行金融计算,防止浮点精度丢失 (BUG-004)
- 📛 证券名称显示:在持仓分析卡片、资产分布、盈亏排行等位置同时显示股票代码和名称
- 📋 证券数据库扩展:新增 Intel Corp. (INTC) 证券记录
- 🔍 回退逻辑增强:确保证券名称为空时显示代码而非空白
- 📈 腾讯行情解析升级:精准解析股票名称(索引1),支持港/A/美股及 ETF 名称自动获取
- 🔀 多市场涨跌解析修复:重构腾讯行情多市场适配层,美股(索引4/5)与港A股(索引31/32)使用差异化索引解析涨跌数据
- 🇨🇳 GBK中文解码修复:改用 `arrayBuffer() + TextDecoder('gbk')` 替代 `text()`,彻底解决A股/港股中文股票名称乱码问题
- 💱 JisuAPI实时汇率:接入 JisuAPI 获取实时汇率,缓存1小时,支持 CNY/HKD/USD 转换
- 📊 资产配置动态图:环形图改由后端实时聚合持仓数据驱动,支持 Tooltip 和百分比显示
- 🎨 资产配置图表优化:精美毛玻璃 Tooltip、颜色图标、去除生硬描边、useMemo 性能优化
- 💱 全局货币联动:资产配置图表数值随 CNY/USD/HKD 切换实时转换
- 📝 交易流水增强:新增证券名称列,显示"名称+代码"双行格式
- 💹 全局汇率展示:在导航栏实时显示 USD/CNY/HKD 汇率信息
- 🔧 BUG-202 修复:修正 `convertCurrency` 汇率换算逻辑(原逻辑除法/乘法颠倒,导致 USD→CNY 换算失效)
- 🔧 BUG-201 修复:腾讯行情 API 获取失败时,`priceAvailable` 标记配合前端显示 "N/A" 替代虚假 0%
- 🔧 BUG-203 增强:持仓分析 `name` 字段确保回退到 `pos.symbol`,名称永不空
- 💹 Decimal 精度保障:所有盈亏/汇率计算均使用 Prisma.Decimal,防止浮点精度丢失
2026-04-13 18:41:37 +08:00

416 lines
12 KiB
TypeScript

import { Prisma } from '@prisma/client'
const mockFindUnique = jest.fn()
const mockCreate = jest.fn()
const mockUpdate = jest.fn()
const mockDelete = jest.fn()
const mockAccountUpdate = jest.fn()
const mockPositionFindUnique = jest.fn()
const mockPositionUpdate = jest.fn()
const mockPositionCreate = jest.fn()
const mockPositionDelete = jest.fn()
jest.mock('@/lib/prisma', () => ({
prisma: {
transaction: {
create: (...args: any[]) => mockCreate(...args),
findUnique: (...args: any[]) => mockFindUnique(...args),
update: (...args: any[]) => mockUpdate(...args),
delete: (...args: any[]) => mockDelete(...args),
},
account: {
update: (...args: any[]) => mockAccountUpdate(...args),
},
position: {
findUnique: (...args: any[]) => mockPositionFindUnique(...args),
update: (...args: any[]) => mockPositionUpdate(...args),
create: (...args: any[]) => mockPositionCreate(...args),
delete: (...args: any[]) => mockPositionDelete(...args),
},
$transaction: async (callback: (tx: any) => Promise<any>) => {
const tx = {
transaction: {
create: mockCreate,
findUnique: mockFindUnique,
update: mockUpdate,
delete: mockDelete,
},
account: { update: mockAccountUpdate },
position: {
findUnique: mockPositionFindUnique,
update: mockPositionUpdate,
create: mockPositionCreate,
delete: mockPositionDelete,
},
}
return callback(tx)
},
},
}))
import { POST, DELETE } from '../src/app/api/transactions/route'
describe('Transactions API - POST (创建交易)', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('DEPOSIT / WITHDRAW', () => {
it('入金增加账户余额', async () => {
mockCreate.mockResolvedValue({
id: 'txn-1',
type: 'DEPOSIT',
amount: new Prisma.Decimal('5000'),
accountId: 'acc-1',
})
mockAccountUpdate.mockResolvedValue({ id: 'acc-1', balance: new Prisma.Decimal('15000') })
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: 'acc-1',
type: 'DEPOSIT',
amount: '5000',
currency: 'USD',
executedAt: '2024-01-15T10:00:00Z',
}),
})
const res = await POST(req)
expect(res.status).toBe(201)
expect(mockAccountUpdate).toHaveBeenCalledWith({
where: { id: 'acc-1' },
data: { balance: { increment: new Prisma.Decimal('5000') } },
})
})
it('出金减少账户余额', async () => {
mockCreate.mockResolvedValue({
id: 'txn-1',
type: 'WITHDRAW',
amount: new Prisma.Decimal('3000'),
accountId: 'acc-1',
})
mockAccountUpdate.mockResolvedValue({ id: 'acc-1', balance: new Prisma.Decimal('7000') })
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: 'acc-1',
type: 'WITHDRAW',
amount: '3000',
currency: 'USD',
executedAt: '2024-01-15T10:00:00Z',
}),
})
const res = await POST(req)
expect(res.status).toBe(201)
expect(mockAccountUpdate).toHaveBeenCalledWith({
where: { id: 'acc-1' },
data: { balance: { increment: new Prisma.Decimal('-3000') } },
})
})
})
describe('BUY 买入持仓', () => {
it('新建持仓 - 首次买入', async () => {
mockCreate.mockResolvedValue({
id: 'txn-1',
type: 'BUY',
symbol: 'BTC',
quantity: new Prisma.Decimal('0.5'),
price: new Prisma.Decimal('50000'),
amount: new Prisma.Decimal('25000'),
currency: 'USD',
})
mockPositionFindUnique.mockResolvedValue(null)
mockPositionCreate.mockResolvedValue({
id: 'pos-1',
accountId: 'acc-1',
symbol: 'BTC',
quantity: new Prisma.Decimal('0.5'),
averageCost: new Prisma.Decimal('50000'),
currency: 'USD',
})
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: 'acc-1',
type: 'BUY',
symbol: 'BTC',
quantity: '0.5',
price: '50000',
amount: '25000',
currency: 'USD',
executedAt: '2024-01-15T10:00:00Z',
}),
})
const res = await POST(req)
expect(res.status).toBe(201)
expect(mockPositionCreate).toHaveBeenCalledWith({
data: {
accountId: 'acc-1',
symbol: 'BTC',
quantity: new Prisma.Decimal('0.5'),
averageCost: new Prisma.Decimal('50000'),
currency: 'USD',
},
})
})
it('更新持仓 - 计算新平均成本', async () => {
const existingPosition = {
id: 'pos-1',
accountId: 'acc-1',
symbol: 'AAPL',
quantity: new Prisma.Decimal('10'),
averageCost: new Prisma.Decimal('150'),
currency: 'USD',
}
mockPositionFindUnique.mockResolvedValue(existingPosition)
mockPositionUpdate.mockResolvedValue({
...existingPosition,
quantity: new Prisma.Decimal('20'),
averageCost: new Prisma.Decimal('155'),
})
mockCreate.mockResolvedValue({ id: 'txn-1', type: 'BUY' })
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: 'acc-1',
type: 'BUY',
symbol: 'AAPL',
quantity: '10',
price: '160',
amount: '1600',
currency: 'USD',
executedAt: '2024-01-15T10:00:00Z',
}),
})
const res = await POST(req)
expect(res.status).toBe(201)
const updateCall = mockPositionUpdate.mock.calls[0][0]
const newQty = updateCall.data.quantity
const newAvgCost = updateCall.data.averageCost
expect(newQty.toString()).toBe('20')
expect(newAvgCost.toString()).toBe('155')
})
})
describe('SELL 卖出持仓', () => {
it('卖出减少持仓数量', async () => {
const existingPosition = {
id: 'pos-1',
accountId: 'acc-1',
symbol: 'AAPL',
quantity: new Prisma.Decimal('10'),
averageCost: new Prisma.Decimal('150'),
currency: 'USD',
}
mockPositionFindUnique.mockResolvedValue(existingPosition)
mockPositionUpdate.mockResolvedValue({
...existingPosition,
quantity: new Prisma.Decimal('5'),
})
mockCreate.mockResolvedValue({ id: 'txn-1', type: 'SELL' })
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: 'acc-1',
type: 'SELL',
symbol: 'AAPL',
quantity: '5',
price: '180',
amount: '900',
currency: 'USD',
executedAt: '2024-01-15T10:00:00Z',
}),
})
const res = await POST(req)
expect(res.status).toBe(201)
const updateCall = mockPositionUpdate.mock.calls[0][0]
expect(updateCall.data.quantity.toString()).toBe('5')
})
it('卖出清空持仓 - 数量归零', async () => {
const existingPosition = {
id: 'pos-1',
accountId: 'acc-1',
symbol: 'AAPL',
quantity: new Prisma.Decimal('10'),
averageCost: new Prisma.Decimal('150'),
currency: 'USD',
}
mockPositionFindUnique.mockResolvedValue(existingPosition)
mockPositionUpdate.mockResolvedValue({
...existingPosition,
quantity: new Prisma.Decimal('0'),
})
mockCreate.mockResolvedValue({ id: 'txn-1', type: 'SELL' })
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accountId: 'acc-1',
type: 'SELL',
symbol: 'AAPL',
quantity: '10',
price: '180',
amount: '1800',
currency: 'USD',
executedAt: '2024-01-15T10:00:00Z',
}),
})
const res = await POST(req)
expect(res.status).toBe(201)
const updateCall = mockPositionUpdate.mock.calls[0][0]
expect(updateCall.data.quantity.toString()).toBe('0')
})
})
})
describe('Transactions API - DELETE (删除交易)', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('撤销BUY', () => {
it('撤销买入 - 按比例还原平均成本', async () => {
const existingPosition = {
id: 'pos-1',
accountId: 'acc-1',
symbol: 'AAPL',
quantity: new Prisma.Decimal('20'),
averageCost: new Prisma.Decimal('155'),
currency: 'USD',
}
const buyTransaction = {
id: 'txn-buy-1',
accountId: 'acc-1',
type: 'BUY' as const,
symbol: 'AAPL',
quantity: new Prisma.Decimal('10'),
price: new Prisma.Decimal('160'),
amount: new Prisma.Decimal('1600'),
}
mockFindUnique.mockResolvedValue(buyTransaction)
mockPositionFindUnique.mockResolvedValue(existingPosition)
mockPositionUpdate.mockResolvedValue({
...existingPosition,
quantity: new Prisma.Decimal('10'),
averageCost: new Prisma.Decimal('150'),
})
mockDelete.mockResolvedValue({ id: 'txn-buy-1' })
mockAccountUpdate.mockResolvedValue({})
const req = new Request('http://localhost/api/transactions?id=txn-buy-1', {
method: 'DELETE',
})
const res = await DELETE(req)
expect(res.status).toBe(200)
const positionUpdateCall = mockPositionUpdate.mock.calls[0][0]
expect(positionUpdateCall.data.quantity.toString()).toBe('10')
})
it('撤销买入 - 全部撤销删除持仓', async () => {
const existingPosition = {
id: 'pos-1',
accountId: 'acc-1',
symbol: 'AAPL',
quantity: new Prisma.Decimal('10'),
averageCost: new Prisma.Decimal('160'),
currency: 'USD',
}
const buyTransaction = {
id: 'txn-buy-1',
accountId: 'acc-1',
type: 'BUY' as const,
symbol: 'AAPL',
quantity: new Prisma.Decimal('10'),
price: new Prisma.Decimal('160'),
amount: new Prisma.Decimal('1600'),
}
mockFindUnique.mockResolvedValue(buyTransaction)
mockPositionFindUnique.mockResolvedValue(existingPosition)
mockPositionUpdate.mockResolvedValue({
id: 'pos-1',
accountId: 'acc-1',
symbol: 'AAPL',
quantity: new Prisma.Decimal('0'),
averageCost: new Prisma.Decimal('160'),
currency: 'USD',
})
mockDelete.mockResolvedValue({ id: 'txn-buy-1' })
mockAccountUpdate.mockResolvedValue({})
const req = new Request('http://localhost/api/transactions?id=txn-buy-1', {
method: 'DELETE',
})
const res = await DELETE(req)
expect(res.status).toBe(200)
})
})
describe('撤销SELL', () => {
it('撤销卖出 - 恢复持仓数量', async () => {
const existingPosition = {
id: 'pos-1',
accountId: 'acc-1',
symbol: 'AAPL',
quantity: new Prisma.Decimal('15'),
averageCost: new Prisma.Decimal('150'),
currency: 'USD',
}
const sellTransaction = {
id: 'txn-sell-1',
accountId: 'acc-1',
type: 'SELL' as const,
symbol: 'AAPL',
quantity: new Prisma.Decimal('5'),
price: new Prisma.Decimal('180'),
amount: new Prisma.Decimal('900'),
}
mockFindUnique.mockResolvedValue(sellTransaction)
mockPositionFindUnique.mockResolvedValue(existingPosition)
mockPositionUpdate.mockResolvedValue({
...existingPosition,
quantity: new Prisma.Decimal('20'),
})
mockDelete.mockResolvedValue({ id: 'txn-sell-1' })
mockAccountUpdate.mockResolvedValue({})
const req = new Request('http://localhost/api/transactions?id=txn-sell-1', {
method: 'DELETE',
})
const res = await DELETE(req)
expect(res.status).toBe(200)
const positionUpdateCall = mockPositionUpdate.mock.calls[0][0]
expect(positionUpdateCall.data.quantity.toString()).toBe('20')
})
})
})