Fix Story 1.3: StateStore spec compliance and minor cleanup

Critical Fix:
- StateStore now uses global Hooks.callAll directly (per spec)
- Removed hooks parameter from StateStore constructor
- Updated module.js to pass only adapter.settings
- Updated tests to stub globalThis.Hooks

Minor Cleanup:
- Fixed misleading warning in SocketHandler.registerPendingOp
- Added clarifying comment for setMatrix _revision behavior

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-22 11:38:45 +02:00
parent 110b295a7b
commit 5ba7717ecd
17 changed files with 2391 additions and 55 deletions
+237
View File
@@ -0,0 +1,237 @@
// @ts-nocheck
/**
* tests/unit/core/SocketHandler.test.js
*
* Story 1.3 — Full unit coverage for SocketHandler.
* Uses vitest fake timers for timeout/retry/revert paths.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SocketHandler, SOCKET_TIMEOUT_MS } from '../../../src/core/SocketHandler.js';
import { MAX_PAYLOAD_BYTES } from '../../../src/contracts/socket-message.js';
// ─── Stubs ────────────────────────────────────────────────────────────────────
function makeSocket() {
return { emit: vi.fn(), on: vi.fn(), off: vi.fn() };
}
function makeHooks() {
return { on: vi.fn(), once: vi.fn() };
}
function makeHandler() {
return { onRevert: vi.fn() };
}
/**
* A minimal PendingOp-like object.
* @param {string} [opId]
* @returns {object}
*/
function makePendingOp(opId = 'op-test-1') {
return { opId, userId: 'user-1', targetState: 'hidden', baseRevision: 0, timeoutId: null };
}
// ─── Constructor ─────────────────────────────────────────────────────────────
describe('SocketHandler constructor', () => {
it('creates instance in not-ready state', () => {
const h = new SocketHandler(makeSocket(), makeHooks());
expect(h._isReady).toBe(false);
expect(h._pendingOps.size).toBe(0);
expect(h._messageQueue).toHaveLength(0);
});
});
// ─── setReady() ──────────────────────────────────────────────────────────────
describe('SocketHandler.setReady()', () => {
it('marks handler as ready', () => {
const h = new SocketHandler(makeSocket(), makeHooks());
h.setReady(makeHandler());
expect(h._isReady).toBe(true);
});
it('drains queued messages in order', () => {
const socket = makeSocket();
const h = new SocketHandler(socket, makeHooks());
h.emit('event-a', { id: 1 });
h.emit('event-b', { id: 2 });
expect(socket.emit).not.toHaveBeenCalled();
h.setReady(makeHandler());
expect(socket.emit).toHaveBeenCalledTimes(2);
expect(socket.emit.mock.calls[0]).toEqual(['event-a', { id: 1 }]);
expect(socket.emit.mock.calls[1]).toEqual(['event-b', { id: 2 }]);
});
it('accepts null handler and logs warn', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const h = new SocketHandler(makeSocket(), makeHooks());
h.setReady(null);
expect(h._isReady).toBe(true);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[ScryingPool]'));
warnSpy.mockRestore();
});
});
// ─── emit() ──────────────────────────────────────────────────────────────────
describe('SocketHandler.emit()', () => {
it('queues message when not ready', () => {
const socket = makeSocket();
const h = new SocketHandler(socket, makeHooks());
h.emit('test-event', { a: 1 });
expect(socket.emit).not.toHaveBeenCalled();
expect(h._messageQueue).toHaveLength(1);
});
it('emits directly when ready', () => {
const socket = makeSocket();
const h = new SocketHandler(socket, makeHooks());
h.setReady(makeHandler());
h.emit('test-event', { a: 1 });
expect(socket.emit).toHaveBeenCalledWith('test-event', { a: 1 });
});
it('throws when payload exceeds MAX_PAYLOAD_BYTES', () => {
const h = new SocketHandler(makeSocket(), makeHooks());
h.setReady(makeHandler());
const bigPayload = { data: 'x'.repeat(MAX_PAYLOAD_BYTES) };
expect(() => h.emit('test-event', bigPayload)).toThrow(Error);
});
it('does NOT throw for payload just under MAX_PAYLOAD_BYTES', () => {
const h = new SocketHandler(makeSocket(), makeHooks());
h.setReady(makeHandler());
const payload = { data: 'x'.repeat(MAX_PAYLOAD_BYTES - 20) };
expect(() => h.emit('test-event', payload)).not.toThrow();
});
});
// ─── registerPendingOp() + confirmPendingOp() ─────────────────────────────────
describe('SocketHandler.registerPendingOp() and confirmPendingOp()', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('registers a pending op', () => {
const h = new SocketHandler(makeSocket(), makeHooks());
const op = makePendingOp();
h.registerPendingOp(op);
expect(h._pendingOps.has('op-test-1')).toBe(true);
});
it('confirms a pending op — clears it and cancels timer', () => {
const h = new SocketHandler(makeSocket(), makeHooks());
const op = makePendingOp();
h.registerPendingOp(op);
h.confirmPendingOp('op-test-1');
expect(h._pendingOps.has('op-test-1')).toBe(false);
});
it('confirmPendingOp with unknown opId is a no-op (stale echo)', () => {
const h = new SocketHandler(makeSocket(), makeHooks());
expect(() => h.confirmPendingOp('unknown-op-id')).not.toThrow();
});
it('confirms op before timeout — no timeout fires', () => {
const socket = makeSocket();
const h = new SocketHandler(socket, makeHooks());
h.setReady(makeHandler());
const op = makePendingOp();
h.registerPendingOp(op);
h.confirmPendingOp('op-test-1');
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 3);
// No retry emit should have occurred
expect(socket.emit).not.toHaveBeenCalled();
});
});
// ─── Timeout → retry → revert ────────────────────────────────────────────────
describe('SocketHandler timeout / retry / revert', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('retries once after first timeout (re-emits the op)', () => {
const socket = makeSocket();
const h = new SocketHandler(socket, makeHooks());
h.setReady(makeHandler());
const op = makePendingOp();
h.registerPendingOp(op);
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS);
expect(socket.emit).toHaveBeenCalledTimes(1);
// Expect cleaned payload without timeoutId
expect(socket.emit).toHaveBeenCalledWith('scrying-pool.visibility.set', {
opId: 'op-test-1',
userId: 'user-1',
targetState: 'hidden',
baseRevision: 0,
});
expect(h._pendingOps.has('op-test-1')).toBe(true); // not removed yet
});
it('calls handler.onRevert and warns after second timeout', () => {
const socket = makeSocket();
const handler = makeHandler();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const h = new SocketHandler(socket, makeHooks());
h.setReady(handler);
const op = makePendingOp();
h.registerPendingOp(op);
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // first timeout → retry
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // second timeout → revert
expect(handler.onRevert).toHaveBeenCalledWith(op);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[ScryingPool]'), 'op-test-1');
expect(h._pendingOps.has('op-test-1')).toBe(false);
warnSpy.mockRestore();
});
it('does NOT call handler.onRevert when handler is null', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const h = new SocketHandler(makeSocket(), makeHooks());
h.setReady(null); // no handler
const op = makePendingOp();
h.registerPendingOp(op);
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 2);
// No error thrown is the assertion
warnSpy.mockRestore();
});
it('ignores second timeout if op was confirmed between retry and second timeout', () => {
const handler = makeHandler();
const h = new SocketHandler(makeSocket(), makeHooks());
h.setReady(handler);
const op = makePendingOp();
h.registerPendingOp(op);
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // first timeout → retry
h.confirmPendingOp('op-test-1'); // confirmed during retry window
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS); // second timeout fires but op is gone
expect(handler.onRevert).not.toHaveBeenCalled();
});
it('stale echo (confirmPendingOp after second revert) is a no-op', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const h = new SocketHandler(makeSocket(), makeHooks());
h.setReady(makeHandler());
const op = makePendingOp();
h.registerPendingOp(op);
vi.advanceTimersByTime(SOCKET_TIMEOUT_MS * 2); // full revert
expect(() => h.confirmPendingOp('op-test-1')).not.toThrow();
warnSpy.mockRestore();
});
});
// ─── SOCKET_TIMEOUT_MS constant ──────────────────────────────────────────────
describe('SOCKET_TIMEOUT_MS', () => {
it('is 3000ms', () => {
expect(SOCKET_TIMEOUT_MS).toBe(3000);
});
});
+291
View File
@@ -0,0 +1,291 @@
// @ts-nocheck
/**
* tests/unit/core/StateStore.test.js
*
* Story 1.3 — Full unit coverage for StateStore.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StateStore, VISIBILITY_MATRIX_KEY } from '../../../src/core/StateStore.js';
import { VISIBILITY_STATES } from '../../../src/contracts/visibility-matrix.js';
import { STATE_STORE_SNAPSHOTS } from '../../fixtures/state-store-snapshots.js';
// ─── Settings stub ───────────────────────────────────────────────────────────
function makeSettings(initial = null) {
let stored = initial;
return {
get: vi.fn(() => stored),
set: vi.fn((key, value) => { stored = value; return Promise.resolve(value); }),
_getStored: () => stored,
};
}
// ─── Hooks stub ──────────────────────────────────────────────────────────────
function makeHooksStub() {
return { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
}
// ─── Setup ──────────────────────────────────────────────────────────────────
function setupHooksGlobal() {
globalThis.Hooks = makeHooksStub();
}
function cleanupHooksGlobal() {
delete globalThis.Hooks;
}
// ─── constructor ─────────────────────────────────────────────────────────────
describe('StateStore constructor', () => {
beforeEach(() => {
setupHooksGlobal();
});
afterEach(() => {
cleanupHooksGlobal();
});
it('creates instance with empty matrix and revision 0', () => {
const settings = makeSettings();
const store = new StateStore(settings);
expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} });
});
it('throws TypeError if settings is null', () => {
expect(() => new StateStore(null)).toThrow(TypeError);
});
it('throws TypeError if settings is not an object', () => {
expect(() => new StateStore('not-an-object')).toThrow(TypeError);
});
});
// ─── init() ──────────────────────────────────────────────────────────────────
describe('StateStore.init()', () => {
beforeEach(() => {
setupHooksGlobal();
});
afterEach(() => {
cleanupHooksGlobal();
});
it('hydrates from a valid persisted matrix', () => {
const settings = makeSettings(STATE_STORE_SNAPSHOTS.threeParticipants);
const store = new StateStore(settings);
store.init();
expect(store.getMatrix().matrix).toEqual(STATE_STORE_SNAPSHOTS.threeParticipants.matrix);
});
it('starts fresh when settings.get returns null', () => {
const settings = makeSettings(null);
const store = new StateStore(settings);
store.init();
expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} });
});
it('starts fresh and logs warn on corrupt data', () => {
const settings = makeSettings({ _version: 1, matrix: { 'user-1': 'INVALID_STATE' } });
const store = new StateStore(settings);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
store.init();
expect(store.getMatrix()).toEqual({ _version: 1, matrix: {} });
// Warn is called with message and error details
expect(warnSpy).toHaveBeenCalled();
expect(warnSpy.mock.calls[0][0]).toContain('[ScryingPool]');
warnSpy.mockRestore();
});
it('is idempotent — second call wins', () => {
const settings = makeSettings(STATE_STORE_SNAPSHOTS.threeParticipants);
const store = new StateStore(settings);
store.init();
// Override stored value
settings.get.mockReturnValue(STATE_STORE_SNAPSHOTS.empty);
store.init();
expect(store.getMatrix()).toEqual(STATE_STORE_SNAPSHOTS.empty);
});
});
// ─── getState() ──────────────────────────────────────────────────────────────
describe('StateStore.getState()', () => {
beforeEach(() => {
setupHooksGlobal();
});
afterEach(() => {
cleanupHooksGlobal();
});
it('returns null for unknown userId', () => {
const store = new StateStore(makeSettings());
expect(store.getState('no-such-user')).toBeNull();
});
it('returns the current state after setVisibility', () => {
const store = new StateStore(makeSettings());
store.setVisibility('user-1', 'active');
expect(store.getState('user-1')).toBe('active');
});
});
// ─── getMatrix() ─────────────────────────────────────────────────────────────
describe('StateStore.getMatrix()', () => {
beforeEach(() => {
setupHooksGlobal();
});
afterEach(() => {
cleanupHooksGlobal();
});
it('returns a deep copy — mutations do not affect internal state', () => {
const store = new StateStore(makeSettings());
store.setVisibility('user-1', 'active');
const snapshot = store.getMatrix();
snapshot.matrix['user-1'] = 'hidden';
expect(store.getState('user-1')).toBe('active'); // internal state unchanged
});
it('includes _version field', () => {
const store = new StateStore(makeSettings());
expect(store.getMatrix()._version).toBe(1);
});
});
// ─── setVisibility() ─────────────────────────────────────────────────────────
describe('StateStore.setVisibility()', () => {
beforeEach(() => {
setupHooksGlobal();
});
afterEach(() => {
cleanupHooksGlobal();
});
it('updates in-memory matrix immediately', () => {
const store = new StateStore(makeSettings());
store.setVisibility('user-1', 'hidden');
expect(store.getState('user-1')).toBe('hidden');
});
it('calls settings.set with namespaced key, full matrix, and revision', () => {
const settings = makeSettings();
const store = new StateStore(settings);
store.setVisibility('user-1', 'active');
expect(settings.set).toHaveBeenCalledWith(
VISIBILITY_MATRIX_KEY,
{ _version: 1, _revision: 1, matrix: { 'user-1': 'active' } },
);
});
it('emits scrying-pool:stateChanged via Hooks.callAll', () => {
const store = new StateStore(makeSettings());
store.setVisibility('user-1', 'active');
expect(globalThis.Hooks.callAll).toHaveBeenCalledWith(
'scrying-pool:stateChanged',
expect.objectContaining({ userId: 'user-1', state: 'active', revision: 1 }),
);
});
it('increments revision on each call', () => {
const store = new StateStore(makeSettings());
store.setVisibility('user-1', 'active');
store.setVisibility('user-2', 'hidden');
const event = globalThis.Hooks.callAll.mock.calls[1][1];
expect(event.revision).toBe(2);
});
it('provides previousState = null for first-time set', () => {
const store = new StateStore(makeSettings());
store.setVisibility('user-1', 'active');
const event = globalThis.Hooks.callAll.mock.calls[0][1];
expect(event.previousState).toBeNull();
});
it('provides previousState = old value on update', () => {
const store = new StateStore(makeSettings());
store.setVisibility('user-1', 'active');
store.setVisibility('user-1', 'hidden');
const event = globalThis.Hooks.callAll.mock.calls[1][1];
expect(event.previousState).toBe('active');
});
it('is a no-op for empty userId', () => {
const settings = makeSettings();
const store = new StateStore(settings);
store.setVisibility('', 'active');
expect(settings.set).not.toHaveBeenCalled();
});
it('is a no-op for non-string userId', () => {
const settings = makeSettings();
const store = new StateStore(settings);
store.setVisibility(null, 'active');
expect(settings.set).not.toHaveBeenCalled();
});
it('is a no-op for invalid targetState', () => {
const settings = makeSettings();
const store = new StateStore(settings);
store.setVisibility('user-1', 'NONSENSE_STATE');
expect(settings.set).not.toHaveBeenCalled();
});
it.each(VISIBILITY_STATES)('accepts valid state "%s"', (state) => {
const store = new StateStore(makeSettings());
expect(() => store.setVisibility('user-test', state)).not.toThrow();
expect(store.getState('user-test')).toBe(state);
});
});
// ─── setMatrix() ─────────────────────────────────────────────────────────────
describe('StateStore.setMatrix()', () => {
beforeEach(() => {
setupHooksGlobal();
});
afterEach(() => {
cleanupHooksGlobal();
});
it('replaces the entire matrix from a valid snapshot', () => {
const store = new StateStore(makeSettings());
store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants);
expect(store.getMatrix().matrix).toEqual(STATE_STORE_SNAPSHOTS.threeParticipants.matrix);
});
it('persists via settings.set', () => {
const settings = makeSettings();
const store = new StateStore(settings);
store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants);
expect(settings.set).toHaveBeenCalledWith(
VISIBILITY_MATRIX_KEY,
expect.objectContaining({ matrix: STATE_STORE_SNAPSHOTS.threeParticipants.matrix }),
);
});
it('emits scrying-pool:stateChanged via Hooks.callAll with full matrix', () => {
const store = new StateStore(makeSettings());
store.setMatrix(STATE_STORE_SNAPSHOTS.threeParticipants);
expect(globalThis.Hooks.callAll).toHaveBeenCalledWith(
'scrying-pool:stateChanged',
expect.objectContaining({ matrix: expect.any(Object), revision: 1 }),
);
});
it('throws TypeError on invalid matrix', () => {
const store = new StateStore(makeSettings());
expect(() => store.setMatrix({ _version: 1, matrix: { 'user-1': 'INVALID' } })).toThrow(TypeError);
});
it('deep-copies input — external mutations do not affect internal state', () => {
const store = new StateStore(makeSettings());
const input = { _version: 1, matrix: { 'user-1': 'active' } };
store.setMatrix(input);
input.matrix['user-1'] = 'hidden';
expect(store.getState('user-1')).toBe('active');
});
});