Blog
Connector testingNode.js

Testing Exchange Connectors Without Live Orders

Test one-send dispatch, ambiguous outcomes, duplicate progress, and late acknowledgements with deterministic Node.js tests, then check real SDK routing separately.

Siebly.io2 min readMarkdown

Isolate Dispatch and Progress

A test that mocks an SDK placement as successful cannot establish that the real credentials, endpoint, or request shape work. It can still test an important application rule: the same recorded intent must not be submitted twice just because the first call is unresolved.

Keep that rule above the exchange client and inject the operation at its boundary. A test can then hold a promise open or reject it at a precise point without waiting for a real outage.

Save this as connector-state.mjs. The example uses normalized test results and integer fixture quantities; an exchange adapter must supply the actual response interpretation and quantity representation.

connector-state.mjs

JavaScript
import { isDeepStrictEqual } from 'node:util';

export function createDispatcher(send) {
  const attempts = new Map();
  return (intent) => {
    if (typeof intent?.id !== 'string' || intent.id.trim() === '') {
      throw new Error('Missing intent ID');
    }
    const request = structuredClone(intent);
    const previous = attempts.get(request.id);
    if (previous) {
      if (!isDeepStrictEqual(previous.request, request)) {
        throw new Error('Intent ID reused with different fields');
      }
      return previous.attempt;
    }
    const attempt = Promise.resolve().then(async () => {
      try {
        const result = await send(structuredClone(request));
        if (result.accepted === true) return { outcome: 'accepted', result };
        if (result.accepted === false) return { outcome: 'rejected', result };
        return { outcome: 'unknown' };
      } catch {
        return { outcome: 'unknown' };
      }
    });
    attempts.set(request.id, { request, attempt });
    return attempt;
  };
}

export function applyProgress(previous, event) {
  if (
    !Number.isSafeInteger(event.filled) ||
    event.filled < 0 ||
    event.filled > previous.requested ||
    typeof event.done !== 'boolean'
  ) {
    throw new Error('Invalid normalized progress');
  }
  return {
    ...previous,
    filled: Math.max(previous.filled, event.filled),
    done: previous.done || event.done,
  };
}

The dispatcher rejects missing IDs and changed requests that reuse an ID. It retains the first attempt, including unknown outcomes, and gives the sender a separate copy so request mutation cannot change the stored identity. The map is for a finite test fixture; a running service needs durable records and a retention policy, plus recovery lookups for unresolved attempts.

The progress helper demonstrates cumulative quantity and terminality for a single fixture order. It is not a universal translation of native exchange statuses. Its purpose is to prevent a late acknowledgement or repeated cumulative update from erasing established progress.

Exercise the Failure Cases

Save the tests beside the module and run node --test connector-state.test.mjs:

connector-state.test.mjs

JavaScript
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDispatcher, applyProgress } from './connector-state.mjs';

const intent = { id: 'intent-1', symbol: 'FIXTURE', quantity: 2 };

test('concurrent callers share one dispatch', async () => {
  let calls = 0;
  let release;
  const pending = new Promise((resolve) => {
    release = resolve;
  });
  const dispatch = createDispatcher(() => {
    calls++;
    return pending;
  });
  const first = dispatch(intent);
  const second = dispatch(intent);
  assert.equal(first, second);
  await Promise.resolve();
  assert.equal(calls, 1);
  release({ accepted: true });
  assert.equal((await first).outcome, 'accepted');
});

test('a lost reply stays unknown without another send', async () => {
  let calls = 0;
  const dispatch = createDispatcher(() => {
    calls++;
    throw new Error('lost reply');
  });
  assert.equal((await dispatch(intent)).outcome, 'unknown');
  assert.equal((await dispatch(intent)).outcome, 'unknown');
  assert.equal(calls, 1);
});

test('an explicit rejection is different from an exception', async () => {
  const dispatch = createDispatcher(async () => ({ accepted: false, reason: 'fixture' }));
  assert.equal((await dispatch(intent)).outcome, 'rejected');
});

test('missing IDs and changed requests cannot share a dispatch', async () => {
  let calls = 0;
  const dispatch = createDispatcher(async (request) => {
    calls++;
    request.quantity = 999; // A sender may mutate its own copy.
    return { accepted: true };
  });
  assert.throws(() => dispatch({ symbol: 'FIXTURE' }), /Missing intent ID/);
  await dispatch(intent);
  assert.throws(() => dispatch({ ...intent, quantity: 3 }), /different fields/);
  assert.equal((await dispatch(intent)).outcome, 'accepted');
  assert.equal(calls, 1);
});

test('a partial fill and duplicate update count once', () => {
  const initial = { requested: 2, filled: 0, done: false };
  const partial = applyProgress(initial, { filled: 1, done: false });
  assert.equal(partial.filled, 1);
  assert.deepEqual(applyProgress(partial, { filled: 1, done: false }), partial);
});

test('late acceptance cannot erase a cancelled partial fill', () => {
  const cancelled = { requested: 2, filled: 1, done: true };
  assert.deepEqual(applyProgress(cancelled, { filled: 0, done: false }), cancelled);
});

test('impossible cumulative quantities fail visibly', () => {
  assert.throws(() =>
    applyProgress({ requested: 2, filled: 0, done: false }, { filled: 3, done: true }),
  );
});

Check the Real Adapter Separately

The tests establish application behaviour under controlled inputs. Add fixtures for native business errors and stream envelopes at the actual adapter, then run public and permitted non-production smoke checks for SDK routing and serialization.

A restarted process cannot rely on the dispatcher's memory. Persist command identity and unresolved outcomes before dispatch, and use the order-state workflow to reconcile them. Testnet and demo checks answer different questions from these local tests.

Related articles

Continue from here

Related Siebly resources

All articles

Subscribe on Substack

Complete the Substack form below to join our newsletter. Substack handles all subscriber data directly.