FABRIC/src/web/server.test.ts
jedarden 006e0f5b59 fix(bd-j1t): update tests for uppercase state labels + canonical event types
WorkerDetail now renders statuses in uppercase (ACTIVE/IDLE/ERROR) to
match NeedleState labels. Store only recognizes canonical event types
(bead.completed, not "Task completed") for state transitions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 14:14:45 -04:00

1160 lines
37 KiB
TypeScript

/**
* Tests for FABRIC Web Server API Endpoints
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createWebServer, WebServer } from './server.js';
import { InMemoryEventStore } from '../store.js';
import { resetCrossReferenceManager } from '../crossReferenceManager.js';
import { LogEvent } from '../types.js';
describe('Web Server API Endpoints', () => {
let store: InMemoryEventStore;
let server: WebServer;
let port: number;
const createEvent = (overrides: Partial<LogEvent> = {}): LogEvent => ({
ts: Date.now(),
worker: 'w-test',
level: 'info',
msg: 'Test message',
...overrides,
});
beforeEach(async () => {
store = new InMemoryEventStore();
resetCrossReferenceManager();
// Find an available port
port = 30000 + Math.floor(Math.random() * 1000);
server = createWebServer({
port,
logPath: '/tmp/test-logs',
store,
});
// Start server and wait for it to be ready
await new Promise<void>((resolve) => {
server.on('start', () => resolve());
server.start();
});
});
afterEach(async () => {
// Stop server
await new Promise<void>((resolve) => {
server.on('stop', () => resolve());
server.stop();
});
store.clear();
resetCrossReferenceManager();
});
const fetchApi = async (path: string, options?: RequestInit) => {
const response = await fetch(`http://localhost:${port}${path}`, options);
return response;
};
describe('GET /api/health', () => {
it('should return ok status', async () => {
const response = await fetchApi('/api/health');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(data.status).toBe('ok');
});
it('should include store size', async () => {
store.add(createEvent());
store.add(createEvent());
const response = await fetchApi('/api/health');
const data = await response.json() as any;
expect(data.storeSize).toBe(2);
});
it('should return 0 store size for empty store', async () => {
const response = await fetchApi('/api/health');
const data = await response.json() as any;
expect(data.storeSize).toBe(0);
});
});
describe('GET /api/workers', () => {
it('should return empty array when no workers', async () => {
const response = await fetchApi('/api/workers');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(data).toEqual([]);
});
it('should return all workers', async () => {
store.add(createEvent({ worker: 'w1' }));
store.add(createEvent({ worker: 'w2' }));
store.add(createEvent({ worker: 'w3' }));
const response = await fetchApi('/api/workers');
const data = await response.json() as any;
expect(data).toHaveLength(3);
const ids = data.map((w: { id: string }) => w.id).sort();
expect(ids).toEqual(['w1', 'w2', 'w3']);
});
it('should include worker status', async () => {
store.add(createEvent({ worker: 'w-active', msg: 'bead.claimed' }));
store.add(createEvent({ worker: 'w-error', level: 'error', msg: 'Something failed' }));
store.add(createEvent({ worker: 'w-idle', msg: 'bead.completed' }));
const response = await fetchApi('/api/workers');
const data = await response.json() as any;
const activeWorker = data.find((w: { id: string }) => w.id === 'w-active');
const errorWorker = data.find((w: { id: string }) => w.id === 'w-error');
const idleWorker = data.find((w: { id: string }) => w.id === 'w-idle');
expect(activeWorker.status).toBe('active');
expect(errorWorker.status).toBe('error');
expect(idleWorker.status).toBe('idle');
});
});
describe('GET /api/workers/:id', () => {
it('should return 404 for unknown worker', async () => {
const response = await fetchApi('/api/workers/unknown');
expect(response.status).toBe(404);
const data = await response.json() as any;
expect(data.error).toBe('Worker not found');
});
it('should return worker details', async () => {
store.add(createEvent({ worker: 'w-test', bead: 'bd-123' }));
const response = await fetchApi('/api/workers/w-test');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(data.id).toBe('w-test');
expect(data.activeBead).toBe('bd-123');
});
it('should track completed beads', async () => {
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed', bead: 'bd-1' }));
store.add(createEvent({ worker: 'w-test', msg: 'bead.completed', bead: 'bd-2' }));
const response = await fetchApi('/api/workers/w-test');
const data = await response.json() as any;
expect(data.beadsCompleted).toBe(2);
});
});
describe('GET /api/events', () => {
it('should return empty array when no events', async () => {
const response = await fetchApi('/api/events');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(data).toEqual([]);
});
it('should return recent events', async () => {
store.add(createEvent({ ts: 1000, msg: 'Event 1' }));
store.add(createEvent({ ts: 2000, msg: 'Event 2' }));
store.add(createEvent({ ts: 3000, msg: 'Event 3' }));
const response = await fetchApi('/api/events');
const data = await response.json() as any;
expect(data).toHaveLength(3);
});
it('should filter by worker', async () => {
store.add(createEvent({ worker: 'w1', ts: 1000 }));
store.add(createEvent({ worker: 'w2', ts: 2000 }));
store.add(createEvent({ worker: 'w1', ts: 3000 }));
const response = await fetchApi('/api/events?worker=w1');
const data = await response.json() as any;
expect(data).toHaveLength(2);
expect(data.every((e: LogEvent) => e.worker === 'w1')).toBe(true);
});
it('should filter by level', async () => {
store.add(createEvent({ level: 'info', ts: 1000 }));
store.add(createEvent({ level: 'error', ts: 2000 }));
store.add(createEvent({ level: 'info', ts: 3000 }));
const response = await fetchApi('/api/events?level=error');
const data = await response.json() as any;
expect(data).toHaveLength(1);
expect(data[0].level).toBe('error');
});
it('should respect limit parameter', async () => {
for (let i = 0; i < 150; i++) {
store.add(createEvent({ ts: i }));
}
const response = await fetchApi('/api/events?limit=10');
const data = await response.json() as any;
expect(data).toHaveLength(10);
});
it('should combine filters', async () => {
store.add(createEvent({ worker: 'w1', level: 'info', ts: 1000 }));
store.add(createEvent({ worker: 'w1', level: 'error', ts: 2000 }));
store.add(createEvent({ worker: 'w2', level: 'error', ts: 3000 }));
const response = await fetchApi('/api/events?worker=w1&level=error');
const data = await response.json() as any;
expect(data).toHaveLength(1);
expect(data[0].worker).toBe('w1');
expect(data[0].level).toBe('error');
});
});
describe('GET /api/collisions', () => {
it('should return empty array when no collisions', async () => {
const response = await fetchApi('/api/collisions');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(data).toEqual([]);
});
it('should return active collisions', async () => {
const ts = Date.now();
const path = '/src/test.ts';
// Create collision - two workers modifying same file
store.add(createEvent({
worker: 'w1',
path,
tool: 'Edit',
ts,
}));
store.add(createEvent({
worker: 'w2',
path,
tool: 'Edit',
ts: ts + 1000,
}));
const response = await fetchApi('/api/collisions');
const data = await response.json() as any;
expect(data).toHaveLength(1);
expect(data[0].path).toBe(path);
expect(data[0].workers).toContain('w1');
expect(data[0].workers).toContain('w2');
expect(data[0].isActive).toBe(true);
});
it('should not return old inactive collisions', async () => {
// Single worker = no collision
store.add(createEvent({
worker: 'w1',
path: '/src/test.ts',
tool: 'Edit',
ts: Date.now(),
}));
const response = await fetchApi('/api/collisions');
const data = await response.json() as any;
expect(data).toHaveLength(0);
});
});
describe('GET /api/workers/:id/collisions', () => {
it('should return empty array for worker with no collisions', async () => {
store.add(createEvent({ worker: 'w1' }));
const response = await fetchApi('/api/workers/w1/collisions');
const data = await response.json() as any;
expect(data).toEqual([]);
});
it('should return collisions for worker involved in collisions', async () => {
const ts = Date.now();
const path = '/src/shared.ts';
store.add(createEvent({
worker: 'w1',
path,
tool: 'Edit',
ts,
}));
store.add(createEvent({
worker: 'w2',
path,
tool: 'Edit',
ts: ts + 1000,
}));
const response = await fetchApi('/api/workers/w1/collisions');
const data = await response.json() as any;
expect(data).toHaveLength(1);
expect(data[0].path).toBe(path);
});
it('should return empty for worker not involved in collision', async () => {
const ts = Date.now();
// Create collision between w1 and w2
store.add(createEvent({
worker: 'w1',
path: '/src/a.ts',
tool: 'Edit',
ts,
}));
store.add(createEvent({
worker: 'w2',
path: '/src/a.ts',
tool: 'Edit',
ts: ts + 1000,
}));
// w3 is not involved
store.add(createEvent({ worker: 'w3' }));
const response = await fetchApi('/api/workers/w3/collisions');
const data = await response.json() as any;
expect(data).toHaveLength(0);
});
});
describe('Cross-Reference API', () => {
describe('GET /api/xref/stats', () => {
it('should return cross-reference statistics', async () => {
const response = await fetchApi('/api/xref/stats');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(data).toHaveProperty('totalLinks');
expect(data).toHaveProperty('totalEntities');
expect(data).toHaveProperty('byRelationship');
expect(data).toHaveProperty('byEntityType');
});
it('should track entities after events are added', async () => {
store.add(createEvent({ worker: 'w1', path: '/src/test.ts', bead: 'bd-1' }));
const response = await fetchApi('/api/xref/stats');
const data = await response.json() as any;
// Should have entities after processing events
expect(data.totalEntities).toBeGreaterThanOrEqual(0);
});
});
describe('GET /api/xref/links', () => {
it('should return all links', async () => {
const response = await fetchApi('/api/xref/links');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(Array.isArray(data)).toBe(true);
});
it('should respect limit parameter', async () => {
const response = await fetchApi('/api/xref/links?limit=5');
const data = await response.json() as any;
expect(data.length).toBeLessThanOrEqual(5);
});
it('should filter by minStrength', async () => {
const response = await fetchApi('/api/xref/links?minStrength=0.5');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(Array.isArray(data)).toBe(true);
});
});
describe('GET /api/xref/entities', () => {
it('should return all entities', async () => {
const response = await fetchApi('/api/xref/entities');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(Array.isArray(data)).toBe(true);
});
});
describe('GET /api/xref/entities/:type/:id', () => {
it('should return 404 for unknown entity', async () => {
const response = await fetchApi('/api/xref/entities/worker/unknown-worker');
expect(response.status).toBe(404);
const data = await response.json() as any;
expect(data.error).toBe('Entity not found');
});
it('should return entity details for known entity', async () => {
// The cross-reference manager needs events processed explicitly
// It's a separate system from the store
const { getCrossReferenceManager } = await import('../crossReferenceManager.js');
const xrefManager = getCrossReferenceManager();
// Process the event through the cross-reference manager
const event = createEvent({ worker: 'w-known' });
store.add(event);
xrefManager.processEvent(event);
const response = await fetchApi('/api/xref/entities/worker/w-known');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(data.id).toBe('w-known');
expect(data.type).toBe('worker');
});
});
describe('GET /api/xref/entities/:type/:id/links', () => {
it('should return links for entity', async () => {
store.add(createEvent({ worker: 'w1', path: '/src/test.ts' }));
const response = await fetchApi('/api/xref/entities/worker/w1/links');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(Array.isArray(data)).toBe(true);
});
});
describe('GET /api/xref/entities/:type/:id/related', () => {
it('should return related entities', async () => {
store.add(createEvent({ worker: 'w1', path: '/src/test.ts', bead: 'bd-1' }));
const response = await fetchApi('/api/xref/entities/worker/w1/related');
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(Array.isArray(data)).toBe(true);
});
});
describe('GET /api/xref/path', () => {
it('should return 400 for missing parameters', async () => {
const response = await fetchApi('/api/xref/path');
expect(response.status).toBe(400);
const data = await response.json() as any;
expect(data.error).toContain('Missing required parameters');
});
it('should return 400 for partial parameters', async () => {
const response = await fetchApi('/api/xref/path?sourceType=worker&sourceId=w1');
expect(response.status).toBe(400);
});
it('should return 404 when no path found', async () => {
const response = await fetchApi(
'/api/xref/path?sourceType=worker&sourceId=unknown&targetType=file&targetId=unknown'
);
expect(response.status).toBe(404);
const data = await response.json() as any;
expect(data.error).toBe('No path found between entities');
});
it('should find path between related entities', async () => {
// Create events that link worker to file
store.add(createEvent({ worker: 'w1', path: '/src/test.ts' }));
const response = await fetchApi(
'/api/xref/path?sourceType=worker&sourceId=w1&targetType=file&targetId=/src/test.ts'
);
// May or may not find path depending on how cross-references are built
expect([200, 404]).toContain(response.status);
});
});
});
describe('WebSocket functionality', () => {
it('should expose broadcast method', () => {
expect(server.broadcast).toBeDefined();
expect(typeof server.broadcast).toBe('function');
});
it('should expose broadcastCollisions method', () => {
expect(server.broadcastCollisions).toBeDefined();
expect(typeof server.broadcastCollisions).toBe('function');
});
it('should expose getPort method', () => {
expect(server.getPort).toBeDefined();
expect(server.getPort()).toBe(port);
});
it('should accept WebSocket connections', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
const openPromise = new Promise<void>((resolve) => {
ws.on('open', () => {
ws.close();
resolve();
});
});
await openPromise;
});
it('should send init message on connection', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
const initMessage = await new Promise<any>((resolve) => {
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'init') {
ws.close();
resolve(msg);
}
});
});
expect(initMessage.type).toBe('init');
expect(initMessage.data).toHaveProperty('workers');
expect(initMessage.data).toHaveProperty('recentEvents');
expect(initMessage.data).toHaveProperty('collisions');
expect(Array.isArray(initMessage.data.workers)).toBe(true);
expect(Array.isArray(initMessage.data.recentEvents)).toBe(true);
expect(Array.isArray(initMessage.data.collisions)).toBe(true);
});
it('should include current state in init message', async () => {
// Add some events first
store.add(createEvent({ worker: 'w1', msg: 'Starting work' }));
store.add(createEvent({ worker: 'w2', msg: 'Another event' }));
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
const initMessage = await new Promise<any>((resolve) => {
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'init') {
ws.close();
resolve(msg);
}
});
});
expect(initMessage.data.workers).toHaveLength(2);
const workerIds = initMessage.data.workers.map((w: { id: string }) => w.id).sort();
expect(workerIds).toEqual(['w1', 'w2']);
});
});
describe('WebSocket broadcast', () => {
it('should broadcast events to connected clients', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
// Set up message listener before connection (to catch init)
const messagePromise = new Promise<any>((resolve) => {
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
// Skip init messages, wait for event
if (msg.type === 'event') {
resolve(msg);
}
});
});
// Wait for connection
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Small delay to ensure connection is established and init sent
await new Promise(resolve => setTimeout(resolve, 50));
// Broadcast an event
const testEvent = createEvent({ worker: 'w-broadcast', msg: 'Broadcast test' });
server.broadcast(testEvent);
const message = await messagePromise;
expect(message.type).toBe('event');
expect(message.data.worker).toBe('w-broadcast');
expect(message.data.msg).toBe('Broadcast test');
ws.close();
});
it('should broadcast to multiple clients', async () => {
const WebSocket = (await import('ws')).default;
const clients: any[] = [];
const messagePromises: Promise<any>[] = [];
// Connect multiple clients with listeners set up first
for (let i = 0; i < 3; i++) {
const ws = new WebSocket(`ws://localhost:${port}`);
// Set up listener before connection
const msgPromise = new Promise<any>((resolve) => {
ws.on('message', (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'event') {
resolve(msg);
}
});
});
messagePromises.push(msgPromise);
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
clients.push(ws);
}
// Small delay to ensure all connections are ready
await new Promise(resolve => setTimeout(resolve, 50));
// Broadcast an event
const testEvent = createEvent({ worker: 'w-multi', msg: 'Multi-client broadcast' });
server.broadcast(testEvent);
// All clients should receive the message
const messages = await Promise.all(messagePromises);
expect(messages).toHaveLength(3);
messages.forEach(msg => {
expect(msg.type).toBe('event');
expect(msg.data.worker).toBe('w-multi');
});
// Cleanup
clients.forEach(ws => ws.close());
});
it('should not broadcast to closed clients', async () => {
const WebSocket = (await import('ws')).default;
// Connect and immediately close one client
const closedWs = new WebSocket(`ws://localhost:${port}`);
await new Promise<void>((resolve) => {
closedWs.on('open', () => {
closedWs.close();
resolve();
});
});
// Connect another client that stays open (set up listener first)
const openWs = new WebSocket(`ws://localhost:${port}`);
const messagePromise = new Promise<any>((resolve) => {
openWs.on('message', (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'event') {
resolve(msg);
}
});
});
await new Promise<void>((resolve) => {
openWs.on('open', resolve);
});
// Wait for close to complete
await new Promise<void>((resolve) => setTimeout(resolve, 100));
const testEvent = createEvent({ worker: 'w-after-close', msg: 'After close' });
server.broadcast(testEvent);
const message = await messagePromise;
expect(message.data.worker).toBe('w-after-close');
openWs.close();
});
});
describe('WebSocket broadcastCollisions', () => {
it('should broadcast collision updates', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
// Set up listener for collision message before connection
const messagePromise = new Promise<any>((resolve) => {
ws.on('message', (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'collision') {
resolve(msg);
}
});
});
// Wait for connection
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Small delay to ensure connection is ready
await new Promise(resolve => setTimeout(resolve, 50));
// Create a collision
const ts = Date.now();
store.add(createEvent({ worker: 'w1', path: '/src/collision.ts', tool: 'Edit', ts }));
store.add(createEvent({ worker: 'w2', path: '/src/collision.ts', tool: 'Edit', ts: ts + 100 }));
server.broadcastCollisions();
const message = await messagePromise;
expect(message.type).toBe('collision');
expect(message.data).toHaveProperty('collisions');
expect(message.data).toHaveProperty('workers');
expect(Array.isArray(message.data.collisions)).toBe(true);
ws.close();
});
it('should include worker data in collision broadcast', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
// Set up listener before connection
const messagePromise = new Promise<any>((resolve) => {
ws.on('message', (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'collision') {
resolve(msg);
}
});
});
// Wait for connection
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Small delay to ensure connection is ready
await new Promise(resolve => setTimeout(resolve, 50));
// Add workers
store.add(createEvent({ worker: 'w-collision-1', msg: 'Working' }));
store.add(createEvent({ worker: 'w-collision-2', msg: 'Working' }));
server.broadcastCollisions();
const message = await messagePromise;
expect(message.data.workers).toBeDefined();
expect(Array.isArray(message.data.workers)).toBe(true);
ws.close();
});
});
describe('WebSocket client lifecycle', () => {
it('should handle client disconnect gracefully', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Close the connection
const closePromise = new Promise<void>((resolve) => {
ws.on('close', resolve);
});
ws.close();
await closePromise;
// Server should still work after client disconnect
const response = await fetchApi('/api/health');
expect(response.status).toBe(200);
});
it('should handle multiple connections and disconnections', async () => {
const WebSocket = (await import('ws')).default;
// Connect and disconnect multiple clients rapidly
for (let i = 0; i < 5; i++) {
const ws = new WebSocket(`ws://localhost:${port}`);
await new Promise<void>((resolve) => {
ws.on('open', () => {
setTimeout(() => {
ws.close();
resolve();
}, 50);
});
});
}
// Server should still be responsive
const response = await fetchApi('/api/health');
expect(response.status).toBe(200);
});
it('should handle WebSocket errors gracefully', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Simulate an error by sending invalid data (this should not crash the server)
// The server handles errors in the ws.on('error') handler
ws.terminate();
// Wait a bit for cleanup
await new Promise(resolve => setTimeout(resolve, 100));
// Server should still work
const response = await fetchApi('/api/health');
expect(response.status).toBe(200);
});
});
describe('Error handling', () => {
it('should handle concurrent requests', async () => {
// Add some events first
for (let i = 0; i < 10; i++) {
store.add(createEvent({ worker: `w${i}` }));
}
// Make concurrent requests
const requests = Array(5).fill(null).map(() => fetchApi('/api/workers'));
const responses = await Promise.all(requests);
for (const response of responses) {
expect(response.status).toBe(200);
const data = await response.json() as any;
expect(data).toHaveLength(10);
}
});
it('should return valid JSON for all endpoints', async () => {
const endpoints = [
'/api/health',
'/api/workers',
'/api/events',
'/api/collisions',
'/api/xref/stats',
'/api/xref/links',
'/api/xref/entities',
];
for (const endpoint of endpoints) {
const response = await fetchApi(endpoint);
expect(response.status).toBe(200);
// Should not throw when parsing JSON
const data = await response.json() as any;
expect(data).toBeDefined();
}
});
});
describe('Server lifecycle', () => {
it('should emit start event', () => {
// This was already tested in beforeEach
expect(server.getPort()).toBe(port);
});
it('should not start twice', async () => {
// Server is already started in beforeEach
// Calling start again should be a no-op
server.start();
// Wait a bit to ensure no error
await new Promise(resolve => setTimeout(resolve, 100));
// Server should still be running
const response = await fetchApi('/api/health');
expect(response.status).toBe(200);
});
});
describe('POST /api/events', () => {
it('should accept a valid NEEDLE format event', async () => {
const needleEvent = {
ts: '2026-03-09T12:33:59.517Z',
event: 'bead.claimed',
level: 'info',
session: 'needle-claude-test',
worker: 'claude-code-test',
data: { bead_id: 'bd-123', workspace: '/home/coder/NEEDLE' }
};
const response = await fetchApi('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(needleEvent)
});
expect(response.status).toBe(201);
const data = await response.json() as any;
expect(data.success).toBe(true);
expect(data.event).toBeDefined();
expect(data.event.msg).toBe('bead.claimed');
});
it('should store the event in the store', async () => {
const needleEvent = {
ts: '2026-03-09T12:34:00.000Z',
event: 'worker.started',
worker: 'test-worker-post'
};
const response = await fetchApi('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(needleEvent)
});
expect(response.status).toBe(201);
// Verify the event is in the store
const eventsResponse = await fetchApi('/api/events');
const events = await eventsResponse.json() as any[];
expect(events.some(e => e.worker === 'test-worker-post')).toBe(true);
});
it('should broadcast the event to WebSocket clients', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
// Set up listener for event message
const messagePromise = new Promise<any>((resolve) => {
ws.on('message', (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'event' && msg.data.msg === 'test.broadcast') {
resolve(msg);
}
});
});
// Wait for connection
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
// Small delay to ensure connection is ready
await new Promise(resolve => setTimeout(resolve, 50));
// Post an event
const response = await fetchApi('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ts: new Date().toISOString(),
event: 'test.broadcast',
worker: 'ws-test-worker'
})
});
expect(response.status).toBe(201);
// Wait for WebSocket broadcast
const message = await messagePromise;
expect(message.type).toBe('event');
expect(message.data.msg).toBe('test.broadcast');
ws.close();
});
it('should return 400 for missing ts field', async () => {
const response = await fetchApi('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'test.event',
worker: 'test-worker'
})
});
expect(response.status).toBe(400);
const data = await response.json() as any;
expect(data.error).toContain('Missing required field');
expect(data.message).toContain('ts');
});
it('should return 400 for missing event field', async () => {
const response = await fetchApi('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ts: '2026-03-09T12:34:00.000Z',
worker: 'test-worker'
})
});
expect(response.status).toBe(400);
const data = await response.json() as any;
expect(data.error).toContain('Missing required field');
expect(data.message).toContain('event');
});
it('should return 400 for invalid JSON body', async () => {
const response = await fetchApi('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'not valid json'
});
// Express.json() will reject malformed JSON
expect(response.status).toBe(400);
});
it('should return 400 for array body (arrays fail field validation)', async () => {
const response = await fetchApi('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(['array', 'not', 'object'])
});
expect(response.status).toBe(400);
const data = await response.json() as any;
// Arrays pass the object check but fail field validation
expect(data.error).toContain('Missing required field');
});
it('should accept NEEDLE format with string worker', async () => {
// NEEDLE format can have worker as a string like "runner-provider-model-id"
const needleEvent = {
ts: '2026-03-09T12:36:00.000Z',
event: 'worker.ping',
worker: 'claude-code-glm-5-alpha'
};
const response = await fetchApi('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(needleEvent)
});
expect(response.status).toBe(201);
const data = await response.json() as any;
expect(data.success).toBe(true);
expect(data.event.worker).toBe('claude-code-glm-5-alpha');
});
});
describe('POST /api/events/batch', () => {
it('should accept an array of events', async () => {
const events = [
{ ts: '2026-03-09T12:35:00.000Z', event: 'batch.1', worker: 'batch-worker' },
{ ts: '2026-03-09T12:35:01.000Z', event: 'batch.2', worker: 'batch-worker' },
{ ts: '2026-03-09T12:35:02.000Z', event: 'batch.3', worker: 'batch-worker' }
];
const response = await fetchApi('/api/events/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(events)
});
expect(response.status).toBe(201);
const data = await response.json() as any;
expect(data.success).toBe(true);
expect(data.ingested).toBe(3);
expect(data.total).toBe(3);
});
it('should return 400 for non-array body', async () => {
const response = await fetchApi('/api/events/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ts: '2026-03-09T12:35:00.000Z', event: 'test' })
});
expect(response.status).toBe(400);
const data = await response.json() as any;
expect(data.error).toContain('Invalid request body');
expect(data.message).toContain('array');
});
it('should return 400 for empty array', async () => {
const response = await fetchApi('/api/events/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([])
});
expect(response.status).toBe(400);
const data = await response.json() as any;
expect(data.error).toContain('Empty batch');
});
it('should return errors for invalid events in batch', async () => {
const events = [
{ ts: '2026-03-09T12:35:00.000Z', event: 'valid.event', worker: 'worker' },
{ ts: '2026-03-09T12:35:01.000Z' }, // missing event
{ event: 'missing.ts', worker: 'worker' }, // missing ts
{ ts: '2026-03-09T12:35:02.000Z', event: 'another.valid', worker: 'worker' }
];
const response = await fetchApi('/api/events/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(events)
});
expect(response.status).toBe(201);
const data = await response.json() as any;
expect(data.ingested).toBe(2);
expect(data.total).toBe(4);
expect(data.errors).toBeDefined();
expect(data.errors.length).toBe(2);
});
it('should broadcast all valid events to WebSocket clients', async () => {
const WebSocket = (await import('ws')).default;
const ws = new WebSocket(`ws://localhost:${port}`);
const messages: any[] = [];
const messagePromise = new Promise<void>((resolve) => {
let count = 0;
ws.on('message', (data: Buffer) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'event' && msg.data.msg?.startsWith('batch.broadcast')) {
messages.push(msg);
count++;
if (count === 2) resolve();
}
});
});
await new Promise<void>((resolve) => {
ws.on('open', resolve);
});
await new Promise(resolve => setTimeout(resolve, 50));
const events = [
{ ts: new Date().toISOString(), event: 'batch.broadcast.1', worker: 'batch-worker' },
{ ts: new Date().toISOString(), event: 'batch.broadcast.2', worker: 'batch-worker' }
];
await fetchApi('/api/events/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(events)
});
await messagePromise;
expect(messages.length).toBe(2);
ws.close();
});
});
});