src/session/session.test.ts
v0.1.0 · 6 KB
import { describe, expect, it, vi } from 'vitest';
import { createFakeExchange } from '../testing/fake.js';
import { FIXTURE_ACCOUNT, FIXTURE_KEYS, wireOrder } from '../testing/fixtures.js';
import { createAddSlot, createRateBudget } from '../throttle/index.js';
import { createTradeSession } from './index.js';
function setup(
changes: { dropAddReplies?: boolean; readDelayMs?: number; rejectAfterAck?: string; readOnly?: boolean } = {},
) {
const fake = createFakeExchange({
ackDelayMs: 3,
readDelayMs: changes.readDelayMs ?? 0,
dropAddReplies: changes.dropAddReplies ?? false,
rejectAfterAck: changes.rejectAfterAck,
canonicalAccountId: FIXTURE_ACCOUNT,
});
const confirmed: unknown[] = [];
const session = createTradeSession({
url: 'ws://localhost',
credentials: () => FIXTURE_KEYS,
accountId: () => FIXTURE_ACCOUNT,
dryRun: () => changes.readOnly ?? false,
authTimeoutMs: 70,
subscribeTimeoutMs: 70,
handshakeTimeoutMs: 70,
pingIntervalMs: 1000,
deadAfterMs: 2000,
backoffMinMs: 25,
backoffMaxMs: 100,
stableAfterMs: 500,
authFailBackoffMs: 200,
maxBufferedBytes: 4096,
replyTimeoutMs: 45,
fillGraceMs: 12,
ordersPageLimit: 40,
logWindowMs: 120,
strayFillMs: 300,
strayFillMax: 50,
seenTradesMax: 70,
sentCloidsMax: 90,
closeWaitMs: 200,
httpBodyMax: 2048,
maxPayloadBytes: 2 ** 20,
budget: createRateBudget({
budgetGeneral: 200,
budgetCancel: 200,
fuseFraction: 0.9,
defaultBackoffMs: 300,
rateBackoff: { minMs: 10, maxMs: 8000 },
emaTimeConstantMs: 240,
}),
addSlot: createAddSlot({ staleMs: 500 }),
webSocketFactory: fake.webSocketFactory,
onConfirmedFill: (fill) => confirmed.push(fill),
});
return {
session,
fake,
confirmed,
close: async () => {
await session.close();
fake.close();
},
};
}
const wire = () => ({
symbol: 'BTC-USD',
side: 'BUY' as const,
tif: 'GTC' as const,
quantity: '0.38',
price: '120.25',
reduceOnly: false,
cloid: 'cd'.repeat(16),
});
describe('trade socket correlation and outcomes', () => {
it('authenticates, subscribes, reads and never reuses a sent cloid', async () => {
const test = setup();
try {
test.session.ensureStarted();
expect(await test.session.whenReady(200)).toBe(true);
const first = await test.session.submitOrder(wire(), false, 50, 90);
expect(first.kind).toBe('acked');
const rows = await test.session.getUserOrders(40, 0, 50);
expect(rows.orders).toHaveLength(1);
expect((await test.session.submitOrder(wire(), false, 50, 90)).kind).toBe('not_sent');
expect(test.fake.sent.filter((frame) => frame.type === 'add_order')).toHaveLength(1);
} finally {
await test.close();
}
});
it('holds an unknown sent order without automatic resubmission', async () => {
const test = setup({ dropAddReplies: true });
try {
test.session.ensureStarted();
expect(await test.session.whenReady(200)).toBe(true);
const outcome = await test.session.submitOrder(wire(), false, 20, 40);
expect(outcome.kind).toBe('unknown');
expect(test.fake.orders.size).toBe(1);
expect(test.fake.sent.filter((frame) => frame.type === 'add_order')).toHaveLength(1);
} finally {
await test.close();
}
});
it('waits for a rejection after acknowledgment when requested', async () => {
const test = setup({ rejectAfterAck: 'FAILED_MARGIN_CHECK' });
try {
test.session.ensureStarted();
expect(await test.session.whenReady(200)).toBe(true);
const outcome = await test.session.submitOrder(wire(), false, 50, 90, { settleAfterAckMs: 20 });
expect(outcome.kind).toBe('rejected');
} finally {
await test.close();
}
});
it('blocks writes in read-only mode at the session boundary', async () => {
const test = setup({ readOnly: true });
try {
test.session.ensureStarted();
expect(await test.session.whenReady(200)).toBe(true);
expect((await test.session.submitOrder(wire(), false, 50, 90)).kind).toBe('not_sent');
expect(test.fake.orders.size).toBe(0);
} finally {
await test.close();
}
});
it('invalidates a socket epoch after an uncorrelated read timeout', async () => {
const test = setup({ readDelayMs: 90 });
try {
test.session.ensureStarted();
expect(await test.session.whenReady(200)).toBe(true);
const epoch = test.session.health().epoch;
await expect(test.session.getUserOrders(40, 0, 12)).rejects.toMatchObject({ kind: 'timeout' });
expect(test.session.health().ready).toBe(false);
expect(test.session.health().epoch).toBeGreaterThanOrEqual(epoch);
} finally {
await test.close();
}
});
it.each(['symbol', 'side', 'orderId'] as const)(
'quarantines a contradictory fill %s without confirming it',
async (field) => {
const test = setup({ dropAddReplies: true });
try {
test.session.ensureStarted();
expect(await test.session.whenReady(200)).toBe(true);
const result = test.session.submitOrder(wire(), true, 180, 250);
await vi.waitFor(() => expect(test.fake.orders.size).toBe(1));
const row = [...test.fake.orders.values()][0]!;
test.fake.emit({ order_response: wireOrder(row) });
await Promise.resolve();
const fill = {
trade_id: 'synthetic-trade',
order_id: row.orderId,
symbol: row.symbol,
side: row.side,
quantity: '0.18',
price: row.price,
client_order_id: row.cloid,
};
if (field === 'symbol') fill.symbol = 'ETH-USD';
if (field === 'side') fill.side = 'SELL';
if (field === 'orderId') fill.order_id = '20000000-0000-4000-8000-000000000002';
test.fake.emit({ fill_response: fill });
expect((await result).kind).toBe('unknown');
expect(test.confirmed).toEqual([]);
} finally {
await test.close();
}
},
);
});