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) => { 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') }) }) })