Files
Codeman/test/ws-connection-registry.test.ts
T

112 lines
4.4 KiB
TypeScript
Raw Normal View History

/**
* @fileoverview Unit tests for WsConnectionRegistry (COD-137).
*
* The registry is the pure decision unit extracted out of ws-routes.ts so the
* connection-limit / clientId-eviction logic is testable without driving real
* WebSocket upgrades. Uses plain fake sockets (identity only).
*
* @dependency src/web/ws-connection-registry.ts
*/
import { describe, it, expect } from 'vitest';
import { WsConnectionRegistry } from '../src/web/ws-connection-registry.js';
/** Fake socket — registry only compares identity, so any object works. */
const sock = (label: string) => ({ readyState: 1, label });
describe('WsConnectionRegistry', () => {
it('reconnecting client (same cid) reclaims its slot instead of being rejected at the limit', () => {
const reg = new WsConnectionRegistry(5);
// Fill all 5 slots with distinct clients, one of which is "alice".
for (const c of ['alice', 'b', 'c', 'd', 'e']) {
expect(reg.register('s1', c, sock(c)).admitted).toBe(true);
}
expect(reg.liveCount('s1')).toBe(5);
// Alice's new upgrade lands BEFORE her old socket's async close fires.
const aliceNew = sock('alice-new');
const res = reg.register('s1', 'alice', aliceNew);
expect(res.admitted).toBe(true); // NOT a spurious 4008
expect(res.evictedSocket).toBeDefined(); // old alice socket handed back to close
expect(reg.liveCount('s1')).toBe(5); // slot reused, not double-counted
});
it('still rejects a genuine (N+1)th DISTINCT client', () => {
const reg = new WsConnectionRegistry(5);
for (const c of ['a', 'b', 'c', 'd', 'e']) {
expect(reg.register('s1', c, sock(c)).admitted).toBe(true);
}
const sixth = reg.register('s1', 'f', sock('f'));
expect(sixth.admitted).toBe(false);
expect(sixth.evictedSocket).toBeUndefined();
expect(reg.liveCount('s1')).toBe(5);
});
it('eager removal on terminate frees a slot immediately', () => {
const reg = new WsConnectionRegistry(5);
const sockets = ['a', 'b', 'c', 'd', 'e'].map((c) => {
const s = sock(c);
reg.register('s1', c, s);
return [c, s] as const;
});
expect(reg.register('s1', 'f', sock('f')).admitted).toBe(false);
// Eagerly unregister one (simulating terminate/error, not async close).
reg.unregister('s1', sockets[0][1]);
expect(reg.liveCount('s1')).toBe(4);
// Now a brand-new distinct client is admitted.
expect(reg.register('s1', 'f', sock('f')).admitted).toBe(true);
expect(reg.liveCount('s1')).toBe(5);
});
it('cid-less upgrades are admitted up to the limit and never evict a keyed client', () => {
const reg = new WsConnectionRegistry(5);
const keyed = sock('keyed');
reg.register('s1', 'keyed', keyed);
// Four anonymous upgrades fill the rest of the cap.
for (let i = 0; i < 4; i++) {
const res = reg.register('s1', null, sock(`anon${i}`));
expect(res.admitted).toBe(true);
expect(res.evictedSocket).toBeUndefined(); // never evicts the keyed client
}
expect(reg.liveCount('s1')).toBe(5);
// 6th anonymous is rejected — anonymous sockets count toward the cap.
expect(reg.register('s1', null, sock('anon-extra')).admitted).toBe(false);
// The keyed client is untouched: a same-cid reconnect still reclaims.
const keyedNew = sock('keyed-new');
const res = reg.register('s1', 'keyed', keyedNew);
expect(res.admitted).toBe(true);
expect(res.evictedSocket).toBe(keyed);
});
it('late close of a superseded socket does not evict the reconnected one', () => {
const reg = new WsConnectionRegistry(5);
const old = sock('old');
reg.register('s1', 'alice', old);
const fresh = sock('fresh');
reg.register('s1', 'alice', fresh); // supersede
// The stale socket's async close arrives late — must NOT remove fresh.
reg.unregister('s1', old);
expect(reg.liveCount('s1')).toBe(1);
// Fresh is still the live entry: another reconnect evicts fresh, not old.
const fresher = sock('fresher');
expect(reg.register('s1', 'alice', fresher).evictedSocket).toBe(fresh);
});
it('isolates counts per session', () => {
const reg = new WsConnectionRegistry(2);
reg.register('s1', 'a', sock('a'));
reg.register('s1', 'b', sock('b'));
expect(reg.register('s1', 'c', sock('c')).admitted).toBe(false);
// s2 has its own budget.
expect(reg.register('s2', 'a', sock('a2')).admitted).toBe(true);
expect(reg.liveCount('s2')).toBe(1);
});
});