← Back
import { describe, it, expect, beforeEach, vi } from 'vitest'

const sm = require('../server/state.js')

describe('StateManager — getTopLevels', () => {
  beforeEach(() => {
    sm.books.clear()
    sm.binHistory.clear()
  })

  it('returns top levels by notional within window', () => {
    sm.initBook('BTCUSDT', [
      ['49500', '1'], // notional 49500
      ['49800', '2'], // notional 99600
      ['49900', '0.5'], // notional 24950
    ], [])

    const levels = sm.getTopLevels('BTCUSDT', 'bid', 50000, 10000, 10, 2)
    expect(levels.length).toBe(3)
    // Sorted by notional desc
    expect(levels[0].notional).toBe(99600)
    expect(levels[0].price).toBe(49800)
  })

  it('filters by minNotional', () => {
    sm.initBook('BTCUSDT', [
      ['49500', '1'],  // 49500
      ['49800', '2'],  // 99600
    ], [])

    const levels = sm.getTopLevels('BTCUSDT', 'bid', 50000, 60000, 10, 2)
    expect(levels.length).toBe(1)
    expect(levels[0].price).toBe(49800)
  })

  it('filters by windowPct', () => {
    sm.initBook('BTCUSDT', [
      ['49000', '2'], // 98000, within 2% of 50000
      ['45000', '3'], // 135000, but 10% away — outside window
    ], [])

    const levels = sm.getTopLevels('BTCUSDT', 'bid', 50000, 0, 10, 2)
    expect(levels.length).toBe(1)
    expect(levels[0].price).toBe(49000)
  })

  it('respects limit', () => {
    sm.initBook('BTCUSDT', [
      ['49500', '1'],
      ['49600', '2'],
      ['49700', '3'],
      ['49800', '4'],
    ], [])

    const levels = sm.getTopLevels('BTCUSDT', 'bid', 50000, 0, 2, 2)
    expect(levels.length).toBe(2)
  })

  it('returns ask side correctly', () => {
    sm.initBook('BTCUSDT', [], [
      ['50100', '2'], // 100200
      ['50200', '3'], // 150600
    ])

    const levels = sm.getTopLevels('BTCUSDT', 'ask', 50000, 0, 10, 2)
    expect(levels.length).toBe(2)
    expect(levels[0].price).toBe(50200) // highest notional first
  })

  it('returns empty for unknown symbol', () => {
    expect(sm.getTopLevels('UNKNOWN', 'bid', 100, 0, 10, 2)).toEqual([])
  })

  it('includes distancePct in results', () => {
    sm.initBook('BTCUSDT', [['49500', '2']], [])
    const levels = sm.getTopLevels('BTCUSDT', 'bid', 50000, 0, 10, 2)
    expect(levels[0].distancePct).toBeCloseTo(1.0, 1) // (50000-49500)/50000*100 = 1%
  })
})

describe('StateManager — trackAndEnrichBins', () => {
  beforeEach(() => {
    sm.books.clear()
    sm.binHistory.clear()
    sm._lastBinCleanup = null
  })

  it('creates new history entry for first-seen bin', () => {
    const bins = [{ anchorPrice: 50000, notional: 100000, oldestSeen: null }]
    const enriched = sm.trackAndEnrichBins('BTCUSDT', 'BID', bins, 50500)

    expect(enriched.length).toBe(1)
    expect(enriched[0].isMovingTowardPrice).toBe(false)
    expect(sm.binHistory.size).toBe(1)
  })

  it('updates existing bin history', () => {
    const bins1 = [{ anchorPrice: 50000, notional: 80000 }]
    sm.trackAndEnrichBins('BTCUSDT', 'BID', bins1, 50500)

    const bins2 = [{ anchorPrice: 50000, notional: 120000 }]
    sm.trackAndEnrichBins('BTCUSDT', 'BID', bins2, 50500)

    const entry = sm.binHistory.get('BTCUSDT:BID:50000')
    expect(entry.maxNotional).toBe(120000) // updated to higher
  })

  it('returns enriched bins with oldestSeen from history', () => {
    const bins1 = [{ anchorPrice: 50000, notional: 80000 }]
    sm.trackAndEnrichBins('BTCUSDT', 'BID', bins1, 50500)

    // Simulate a later call
    const bins2 = [{ anchorPrice: 50000, notional: 90000 }]
    const enriched = sm.trackAndEnrichBins('BTCUSDT', 'BID', bins2, 50500)

    expect(enriched[0].oldestSeen).toBeDefined()
    expect(enriched[0].oldestSeen).toBeLessThanOrEqual(Date.now())
  })

  it('enforces hard cap 5000 on binHistory', () => {
    // Force cleanup to run by setting _lastBinCleanup to old
    sm._lastBinCleanup = 0
    // Fill binHistory beyond 5000
    for (let i = 0; i < 5100; i++) {
      sm.binHistory.set(`SYM:BID:${i}`, { oldestSeen: Date.now(), maxNotional: 100, lastUpdate: Date.now() - 90000, isMovingTowardPrice: false })
    }
    // Trigger cleanup via trackAndEnrichBins
    const bins = [{ anchorPrice: 99999, notional: 1000 }]
    sm.trackAndEnrichBins('XXUSDT', 'BID', bins, 100000)

    // After cleanup: stale entries (>60s) should be removed, and hard cap applied
    expect(sm.binHistory.size).toBeLessThanOrEqual(5000)
  })
})

describe('StateManager — MAX_BOOKS eviction', () => {
  beforeEach(() => {
    sm.books.clear()
    sm.binHistory.clear()
  })

  it('evicts oldest book when at capacity', () => {
    sm.MAX_BOOKS = 3 // temp lower for test

    sm.initBook('AAA', [['100', '1']], [])
    sm.books.get('AAA')._lastActivity = 1000 // oldest

    sm.initBook('BBB', [['200', '1']], [])
    sm.books.get('BBB')._lastActivity = 2000

    sm.initBook('CCC', [['300', '1']], [])
    sm.books.get('CCC')._lastActivity = 3000

    // Adding 4th should evict AAA (oldest)
    sm.initBook('DDD', [['400', '1']], [])

    expect(sm.books.has('AAA')).toBe(false)
    expect(sm.books.has('DDD')).toBe(true)
    expect(sm.books.size).toBe(3)

    sm.MAX_BOOKS = 600 // restore
  })
})

describe('StateManager — gap detection / resync', () => {
  beforeEach(() => {
    sm.books.clear()
    sm.binHistory.clear()
    sm._resyncHandler = null
  })

  it('calls resync handler on gap', () => {
    const handler = vi.fn()
    sm.setResyncHandler(handler)

    sm.initBook('BTCUSDT', [['50000', '1']], [])
    sm.books.get('BTCUSDT').lastUpdateId = 100

    // Gap: expected 101, got 200
    sm.processDelta('BTCUSDT', { U: 200, u: 210, b: [['49000', '1']], a: [] })

    expect(handler).toHaveBeenCalledWith('BTCUSDT')
  })

  it('does not apply delta when gap detected', () => {
    sm.setResyncHandler(vi.fn())
    sm.initBook('BTCUSDT', [['50000', '1']], [])
    sm.books.get('BTCUSDT').lastUpdateId = 100

    sm.processDelta('BTCUSDT', { U: 200, u: 210, b: [['49000', '5']], a: [] })

    const book = sm.books.get('BTCUSDT')
    expect(book.bids.has(49000)).toBe(false) // delta was dropped
    expect(book.lastUpdateId).toBe(100) // not updated
  })

  it('fires resync handler on every gap (throttling in index.js)', () => {
    const handler = vi.fn()
    sm.setResyncHandler(handler)

    sm.initBook('BTCUSDT', [['50000', '1']], [])
    sm.books.get('BTCUSDT').lastUpdateId = 100

    // First gap triggers resync
    sm.processDelta('BTCUSDT', { U: 200, u: 210, b: [], a: [] })
    expect(handler).toHaveBeenCalledTimes(1)

    // Second gap — also fires (cooldown is caller's responsibility)
    sm.processDelta('BTCUSDT', { U: 300, u: 310, b: [], a: [] })
    expect(handler).toHaveBeenCalledTimes(2)
  })

  it('applies delta normally when sequence is valid', () => {
    sm.setResyncHandler(vi.fn())
    sm.initBook('BTCUSDT', [['50000', '1']], [])
    sm.books.get('BTCUSDT').lastUpdateId = 100

    // Valid sequence: U=101 (first) u=105 (last)
    sm.processDelta('BTCUSDT', { U: 101, u: 105, b: [['49000', '2']], a: [] })

    const book = sm.books.get('BTCUSDT')
    expect(book.bids.has(49000)).toBe(true)
    expect(book.lastUpdateId).toBe(105)
  })
})

describe('StateManager — removeBook', () => {
  beforeEach(() => {
    sm.books.clear()
  })

  it('removes a book by symbol', () => {
    sm.initBook('BTCUSDT', [['50000', '1']], [])
    expect(sm.books.has('BTCUSDT')).toBe(true)
    sm.removeBook('BTCUSDT')
    expect(sm.books.has('BTCUSDT')).toBe(false)
  })

  it('does nothing for unknown symbol', () => {
    sm.removeBook('UNKNOWN') // should not throw
    expect(sm.books.size).toBe(0)
  })
})

📜 Git History

85e4ebdfix: 16-bug audit — resync storm, memory leaks, API errors, data persistence7 weeks ago
4492574fix+test: comprehensive code audit — 11 bugfixes + 148 new tests8 weeks ago
Show last diff
Loading...