test: Add comprehensive tests for bugs #76, #78, #80

Co-authored-by: mindesbunister <32161838+mindesbunister@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-09 22:27:58 +00:00
parent 63b94016fe
commit 271222fb36
3 changed files with 785 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
/**
* Bug #80 Test: Retry Loop Cooldown Enforcement
*
* Tests that drift-state-verifier respects 5-minute cooldown and doesn't
* repeatedly retry closing positions, which strips protection.
*
* Created: Dec 9, 2025
* Reason: Bug #80 - Retry loop doesn't enforce cooldown properly
*/
import { getDriftStateVerifier } from '../../../lib/monitoring/drift-state-verifier'
// Mock dependencies
jest.mock('../../../lib/drift/client')
jest.mock('../../../lib/drift/orders')
jest.mock('../../../lib/database/trades')
jest.mock('../../../lib/notifications/telegram')
describe('Bug #80: Retry Loop Cooldown', () => {
let verifier: any
let mockClosePosition: jest.Mock
let mockPrisma: any
beforeEach(() => {
jest.clearAllMocks()
// Get verifier instance
verifier = getDriftStateVerifier()
// Access private methods via type assertion for testing
verifier.recentCloseAttempts = new Map()
// Mock Drift service
const { getDriftService } = require('../../../lib/drift/client')
getDriftService.mockResolvedValue({
getPosition: jest.fn().mockResolvedValue({ size: 0, side: 'none' })
})
// Mock closePosition
const ordersModule = require('../../../lib/drift/orders')
mockClosePosition = jest.fn().mockResolvedValue({
success: true,
transactionSignature: 'CLOSE_TX',
realizedPnL: -10.50
})
ordersModule.closePosition = mockClosePosition
// Mock Prisma
const { getPrismaClient } = require('../../../lib/database/trades')
mockPrisma = {
trade: {
findUnique: jest.fn(),
update: jest.fn()
}
}
getPrismaClient.mockReturnValue(mockPrisma)
})
describe('In-Memory Cooldown Map', () => {
it('should allow first close attempt', async () => {
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
mockPrisma.trade.findUnique.mockResolvedValue({
id: 'trade1',
configSnapshot: {}
})
await (verifier as any).retryClose(mismatch)
// Should have called closePosition
expect(mockClosePosition).toHaveBeenCalledWith({
symbol: 'SOL-PERP',
percentToClose: 100,
slippageTolerance: 0.05
})
// Should have recorded attempt in map
expect(verifier.recentCloseAttempts.has('SOL-PERP')).toBe(true)
})
it('should block retry within 5-minute cooldown', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
// Set recent attempt (2 minutes ago)
const twoMinutesAgo = Date.now() - (2 * 60 * 1000)
verifier.recentCloseAttempts.set('SOL-PERP', twoMinutesAgo)
await (verifier as any).retryClose(mismatch)
// Should NOT have called closePosition
expect(mockClosePosition).not.toHaveBeenCalled()
// Should have logged cooldown message
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('COOLDOWN ACTIVE')
)
consoleSpy.mockRestore()
})
it('should allow retry after cooldown expires (> 5 minutes)', async () => {
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
// Set old attempt (6 minutes ago - cooldown expired)
const sixMinutesAgo = Date.now() - (6 * 60 * 1000)
verifier.recentCloseAttempts.set('SOL-PERP', sixMinutesAgo)
mockPrisma.trade.findUnique.mockResolvedValue({
id: 'trade1',
configSnapshot: {}
})
await (verifier as any).retryClose(mismatch)
// Should have called closePosition (cooldown expired)
expect(mockClosePosition).toHaveBeenCalled()
})
it('should log remaining cooldown time', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
// Set recent attempt (90 seconds ago)
const ninetySecondsAgo = Date.now() - (90 * 1000)
verifier.recentCloseAttempts.set('SOL-PERP', ninetySecondsAgo)
await (verifier as any).retryClose(mismatch)
// Should log remaining wait time
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringMatching(/Must wait \d+s more/)
)
consoleSpy.mockRestore()
})
it('should log cooldown map state', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation()
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
// Set recent attempts for multiple symbols
verifier.recentCloseAttempts.set('SOL-PERP', Date.now() - 30000)
verifier.recentCloseAttempts.set('ETH-PERP', Date.now() - 60000)
await (verifier as any).retryClose(mismatch)
// Should log map state
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Cooldown map state:')
)
consoleSpy.mockRestore()
})
})
describe('Database Cooldown Persistence', () => {
it('should check database for cooldown even if map is empty', async () => {
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
// Map is empty (simulates restart)
expect(verifier.recentCloseAttempts.size).toBe(0)
// But database has recent attempt
const twoMinutesAgo = new Date(Date.now() - (2 * 60 * 1000))
mockPrisma.trade.findUnique.mockResolvedValue({
id: 'trade1',
configSnapshot: {
retryCloseTime: twoMinutesAgo.toISOString()
}
})
await (verifier as any).retryClose(mismatch)
// Should NOT have called closePosition (DB cooldown active)
expect(mockClosePosition).not.toHaveBeenCalled()
})
it('should update in-memory map from database', async () => {
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
const oneMinuteAgo = new Date(Date.now() - (1 * 60 * 1000))
mockPrisma.trade.findUnique.mockResolvedValue({
id: 'trade1',
configSnapshot: {
retryCloseTime: oneMinuteAgo.toISOString()
}
})
await (verifier as any).retryClose(mismatch)
// Should have updated map from DB
expect(verifier.recentCloseAttempts.has('SOL-PERP')).toBe(true)
})
it('should persist attempt to database after successful close', async () => {
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
mockPrisma.trade.findUnique.mockResolvedValue({
id: 'trade1',
configSnapshot: {}
})
mockPrisma.trade.update.mockResolvedValue({})
await (verifier as any).retryClose(mismatch)
// Should have updated database with retry time
expect(mockPrisma.trade.update).toHaveBeenCalledWith({
where: { id: 'trade1' },
data: expect.objectContaining({
configSnapshot: expect.objectContaining({
retryCloseAttempted: true,
retryCloseTime: expect.any(String)
})
})
})
})
})
describe('Error Handling', () => {
it('should record attempt even on close failure to prevent spam', async () => {
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
mockPrisma.trade.findUnique.mockResolvedValue({
id: 'trade1',
configSnapshot: {}
})
// Close fails
mockClosePosition.mockResolvedValue({
success: false,
error: 'RPC timeout'
})
await (verifier as any).retryClose(mismatch)
// Should still have recorded attempt
expect(verifier.recentCloseAttempts.has('SOL-PERP')).toBe(true)
})
it('should record attempt on exception to prevent rapid retries', async () => {
const mismatch = {
tradeId: 'trade1',
symbol: 'SOL-PERP',
expectedState: 'closed' as const,
actualState: 'open' as const,
driftSize: 14.47,
dbExitReason: 'TP1',
timeSinceExit: 60000
}
mockPrisma.trade.findUnique.mockRejectedValue(new Error('Database error'))
await (verifier as any).retryClose(mismatch)
// Should have recorded attempt even on error
expect(verifier.recentCloseAttempts.has('SOL-PERP')).toBe(true)
})
})
})

View File

@@ -0,0 +1,183 @@
/**
* Bug #76 Test: Stop-Loss Placement Validation
*
* Tests that placeExitOrders returns failure when SL signature is missing
* and logs critical errors appropriately.
*
* Created: Dec 9, 2025
* Reason: Bug #76 - placeExitOrders() can return SUCCESS with missing SL orders
*/
import { placeExitOrders, PlaceExitOrdersOptions } from '../../../lib/drift/orders'
// Mock dependencies
jest.mock('../../../lib/drift/client')
jest.mock('../../../lib/utils/logger')
jest.mock('../../../config/trading')
describe('Bug #76: Exit Orders Validation', () => {
let mockDriftClient: any
beforeEach(() => {
jest.clearAllMocks()
// Mock Drift service and client
mockDriftClient = {
placePerpOrder: jest.fn()
}
const { getDriftService } = require('../../../lib/drift/client')
getDriftService.mockReturnValue({
getClient: () => mockDriftClient
})
// Mock trading config
const { getMarketConfig } = require('../../../config/trading')
getMarketConfig.mockReturnValue({
driftMarketIndex: 0,
minOrderSize: 0.1
})
})
describe('Single Stop System', () => {
it('should return success when all 3 orders placed (TP1 + TP2 + SL)', async () => {
// Mock successful order placements
mockDriftClient.placePerpOrder
.mockResolvedValueOnce('TP1_SIG') // TP1
.mockResolvedValueOnce('TP2_SIG') // TP2
.mockResolvedValueOnce('SL_SIG') // SL
const options: PlaceExitOrdersOptions = {
symbol: 'SOL-PERP',
positionSizeUSD: 8000,
entryPrice: 140.00,
tp1Price: 141.20,
tp2Price: 142.41,
stopLossPrice: 138.71,
tp1SizePercent: 75,
tp2SizePercent: 0,
direction: 'long',
useDualStops: false
}
const result = await placeExitOrders(options)
expect(result.success).toBe(true)
expect(result.signatures).toHaveLength(3)
expect(result.signatures).toEqual(['TP1_SIG', 'TP2_SIG', 'SL_SIG'])
})
it('should return failure when SL placement fails', async () => {
// Mock TP1 and TP2 success, but SL fails
mockDriftClient.placePerpOrder
.mockResolvedValueOnce('TP1_SIG') // TP1
.mockResolvedValueOnce('TP2_SIG') // TP2
.mockRejectedValueOnce(new Error('Rate limit exceeded')) // SL fails
const options: PlaceExitOrdersOptions = {
symbol: 'SOL-PERP',
positionSizeUSD: 8000,
entryPrice: 140.00,
tp1Price: 141.20,
tp2Price: 142.41,
stopLossPrice: 138.71,
tp1SizePercent: 75,
tp2SizePercent: 0,
direction: 'long',
useDualStops: false
}
const result = await placeExitOrders(options)
expect(result.success).toBe(false)
expect(result.error).toContain('Stop loss placement failed')
})
})
describe('Dual Stop System', () => {
it('should return success when all 4 orders placed (TP1 + TP2 + Soft + Hard)', async () => {
// Mock successful order placements
mockDriftClient.placePerpOrder
.mockResolvedValueOnce('TP1_SIG') // TP1
.mockResolvedValueOnce('TP2_SIG') // TP2
.mockResolvedValueOnce('SOFT_SIG') // Soft Stop
.mockResolvedValueOnce('HARD_SIG') // Hard Stop
const options: PlaceExitOrdersOptions = {
symbol: 'SOL-PERP',
positionSizeUSD: 8000,
entryPrice: 140.00,
tp1Price: 141.20,
tp2Price: 142.41,
stopLossPrice: 138.71,
tp1SizePercent: 75,
tp2SizePercent: 0,
direction: 'long',
useDualStops: true,
softStopPrice: 139.00,
hardStopPrice: 138.50
}
const result = await placeExitOrders(options)
expect(result.success).toBe(true)
expect(result.signatures).toHaveLength(4)
expect(result.signatures).toEqual(['TP1_SIG', 'TP2_SIG', 'SOFT_SIG', 'HARD_SIG'])
})
it('should return failure when soft stop fails', async () => {
mockDriftClient.placePerpOrder
.mockResolvedValueOnce('TP1_SIG') // TP1
.mockResolvedValueOnce('TP2_SIG') // TP2
.mockRejectedValueOnce(new Error('Soft stop failed')) // Soft Stop
const options: PlaceExitOrdersOptions = {
symbol: 'SOL-PERP',
positionSizeUSD: 8000,
entryPrice: 140.00,
tp1Price: 141.20,
tp2Price: 142.41,
stopLossPrice: 138.71,
tp1SizePercent: 75,
tp2SizePercent: 0,
direction: 'long',
useDualStops: true,
softStopPrice: 139.00,
hardStopPrice: 138.50
}
const result = await placeExitOrders(options)
expect(result.success).toBe(false)
expect(result.error).toContain('Soft stop placement failed')
})
it('should return failure when hard stop fails', async () => {
mockDriftClient.placePerpOrder
.mockResolvedValueOnce('TP1_SIG') // TP1
.mockResolvedValueOnce('TP2_SIG') // TP2
.mockResolvedValueOnce('SOFT_SIG') // Soft Stop
.mockRejectedValueOnce(new Error('Hard stop failed')) // Hard Stop
const options: PlaceExitOrdersOptions = {
symbol: 'SOL-PERP',
positionSizeUSD: 8000,
entryPrice: 140.00,
tp1Price: 141.20,
tp2Price: 142.41,
stopLossPrice: 138.71,
tp1SizePercent: 75,
tp2SizePercent: 0,
direction: 'long',
useDualStops: true,
softStopPrice: 139.00,
hardStopPrice: 138.50
}
const result = await placeExitOrders(options)
expect(result.success).toBe(false)
expect(result.error).toContain('Hard stop placement failed')
})
})
})

View File

@@ -0,0 +1,266 @@
/**
* Bug #78 Test: Safe Orphan Removal
*
* Tests that removeTrade() checks Drift position size before canceling orders
* to avoid removing active position protection.
*
* Created: Dec 9, 2025
* Reason: Bug #78 - cancelAllOrders affects ALL positions on symbol
*/
import { PositionManager } from '../../../lib/trading/position-manager'
import { createMockTrade } from '../../helpers/trade-factory'
// Mock dependencies
jest.mock('../../../lib/drift/client')
jest.mock('../../../lib/drift/orders')
jest.mock('../../../lib/pyth/price-monitor')
jest.mock('../../../lib/database/trades')
jest.mock('../../../lib/notifications/telegram')
jest.mock('../../../config/trading')
describe('Bug #78: Safe Orphan Removal', () => {
let manager: PositionManager
let mockDriftService: any
let mockCancelAllOrders: jest.Mock
beforeEach(() => {
jest.clearAllMocks()
// Mock Drift service
mockDriftService = {
getPosition: jest.fn()
}
const { getDriftService } = require('../../../lib/drift/client')
getDriftService.mockReturnValue(mockDriftService)
// Mock cancelAllOrders
const ordersModule = require('../../../lib/drift/orders')
mockCancelAllOrders = jest.fn().mockResolvedValue({ success: true, cancelledCount: 2 })
ordersModule.cancelAllOrders = mockCancelAllOrders
// Mock Pyth price monitor
const { getPythPriceMonitor } = require('../../../lib/pyth/price-monitor')
getPythPriceMonitor.mockReturnValue({
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined)
})
// Mock trading config
const { getMarketConfig } = require('../../../config/trading')
getMarketConfig.mockReturnValue({
driftMarketIndex: 0,
minOrderSize: 0.1
})
manager = new PositionManager()
})
describe('When Drift Position is Closed', () => {
it('should cancel orders when Drift confirms position closed (size = 0)', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP',
direction: 'long'
})
await manager.addTrade(trade)
// Mock Drift position as closed
mockDriftService.getPosition.mockResolvedValue({
size: 0,
side: 'none'
})
await manager.removeTrade(trade.id)
// Should have called cancelAllOrders since position is closed
expect(mockCancelAllOrders).toHaveBeenCalledWith('SOL-PERP')
expect(mockCancelAllOrders).toHaveBeenCalledTimes(1)
})
it('should cancel orders when Drift position size is negligible (< 0.01)', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
// Mock Drift position with negligible size
mockDriftService.getPosition.mockResolvedValue({
size: 0.005,
side: 'long'
})
await manager.removeTrade(trade.id)
// Should cancel since size is negligible
expect(mockCancelAllOrders).toHaveBeenCalledWith('SOL-PERP')
})
})
describe('When Drift Position is Still Open', () => {
it('should NOT cancel orders when Drift shows open position (size >= 0.01)', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
// Mock Drift position as still open
mockDriftService.getPosition.mockResolvedValue({
size: 14.47, // Real position size
side: 'long',
entryPrice: 138.45
})
await manager.removeTrade(trade.id)
// Should NOT have called cancelAllOrders
expect(mockCancelAllOrders).not.toHaveBeenCalled()
})
it('should remove trade from tracking even when skipping order cancellation', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
// Verify trade is tracked
expect((manager as any).activeTrades.has(trade.id)).toBe(true)
// Mock Drift position as open
mockDriftService.getPosition.mockResolvedValue({
size: 10.5,
side: 'long'
})
await manager.removeTrade(trade.id)
// Trade should be removed from tracking
expect((manager as any).activeTrades.has(trade.id)).toBe(false)
// But orders should not be canceled
expect(mockCancelAllOrders).not.toHaveBeenCalled()
})
it('should log warning when skipping cancellation for safety', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
mockDriftService.getPosition.mockResolvedValue({
size: 12.28,
side: 'long'
})
await manager.removeTrade(trade.id)
// Should have logged safety warning
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('SAFETY CHECK')
)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Skipping order cancellation')
)
consoleSpy.mockRestore()
})
})
describe('Error Handling', () => {
it('should NOT cancel orders on Drift query error (err on side of caution)', async () => {
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
// Mock Drift error
mockDriftService.getPosition.mockRejectedValue(new Error('RPC error'))
await manager.removeTrade(trade.id)
// Should NOT cancel orders on error (safety first)
expect(mockCancelAllOrders).not.toHaveBeenCalled()
// But should remove from tracking
expect((manager as any).activeTrades.has(trade.id)).toBe(false)
})
it('should log error when Drift check fails', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation()
const trade = createMockTrade({
id: 'trade1',
symbol: 'SOL-PERP'
})
await manager.addTrade(trade)
mockDriftService.getPosition.mockRejectedValue(new Error('Network timeout'))
await manager.removeTrade(trade.id)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Error checking Drift position')
)
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Removing from tracking without canceling orders')
)
consoleSpy.mockRestore()
consoleWarnSpy.mockRestore()
})
})
describe('Multiple Positions on Same Symbol', () => {
it('should preserve orders when removing old position while new position exists', async () => {
const oldTrade = createMockTrade({
id: 'old-trade',
symbol: 'SOL-PERP',
direction: 'long'
})
const newTrade = createMockTrade({
id: 'new-trade',
symbol: 'SOL-PERP',
direction: 'long'
})
// Add both trades (simulating orphan detection scenario)
await manager.addTrade(oldTrade)
await manager.addTrade(newTrade)
// Mock Drift showing active position (belongs to new trade)
mockDriftService.getPosition.mockResolvedValue({
size: 14.47,
side: 'long',
entryPrice: 138.45
})
// Remove old trade (orphan cleanup)
await manager.removeTrade(oldTrade.id)
// Should NOT cancel orders (protects new trade)
expect(mockCancelAllOrders).not.toHaveBeenCalled()
// Old trade removed
expect((manager as any).activeTrades.has(oldTrade.id)).toBe(false)
// New trade still tracked
expect((manager as any).activeTrades.has(newTrade.id)).toBe(true)
})
})
})