test(packages): add file-store tests for @bytelyst/event-store (9 tests), SSE hub tests for @bytelyst/fastify-sse (12 tests)
This commit is contained in:
parent
07d698e700
commit
ac525563dc
113
packages/event-store/src/file-store.test.ts
Normal file
113
packages/event-store/src/file-store.test.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
import { FileEventStore } from './file-store.js';
|
||||||
|
import type { StoredEvent } from './types.js';
|
||||||
|
|
||||||
|
function makeEvent(overrides?: Partial<StoredEvent>): StoredEvent {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: 'test.event',
|
||||||
|
userId: 'u1',
|
||||||
|
productId: 'testprod',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FileEventStore', () => {
|
||||||
|
let store: FileEventStore;
|
||||||
|
let filePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
filePath = join(
|
||||||
|
tmpdir(),
|
||||||
|
`event-store-test-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`
|
||||||
|
);
|
||||||
|
store = new FileEventStore({ filePath });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await rm(filePath);
|
||||||
|
} catch {
|
||||||
|
/* may not exist */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends and retrieves events', async () => {
|
||||||
|
await store.append(makeEvent());
|
||||||
|
expect(await store.count()).toBe(1);
|
||||||
|
const recent = await store.recent();
|
||||||
|
expect(recent).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists multiple events across reads', async () => {
|
||||||
|
await store.append(makeEvent({ id: 'e1' }));
|
||||||
|
await store.append(makeEvent({ id: 'e2' }));
|
||||||
|
await store.append(makeEvent({ id: 'e3' }));
|
||||||
|
expect(await store.count()).toBe(3);
|
||||||
|
const recent = await store.recent(2);
|
||||||
|
expect(recent).toHaveLength(2);
|
||||||
|
expect(recent[0].id).toBe('e2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries by userId', async () => {
|
||||||
|
await store.append(makeEvent({ userId: 'u1' }));
|
||||||
|
await store.append(makeEvent({ userId: 'u2' }));
|
||||||
|
await store.append(makeEvent({ userId: 'u1' }));
|
||||||
|
const results = await store.query({ userId: 'u1' });
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries by type', async () => {
|
||||||
|
await store.append(makeEvent({ type: 'task.created' }));
|
||||||
|
await store.append(makeEvent({ type: 'schedule.generated' }));
|
||||||
|
const results = await store.query({ type: 'task.created' });
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries with time range', async () => {
|
||||||
|
await store.append(makeEvent({ timestamp: '2026-01-01T00:00:00Z' }));
|
||||||
|
await store.append(makeEvent({ timestamp: '2026-03-01T00:00:00Z' }));
|
||||||
|
await store.append(makeEvent({ timestamp: '2026-06-01T00:00:00Z' }));
|
||||||
|
const results = await store.query({
|
||||||
|
after: '2026-02-01T00:00:00Z',
|
||||||
|
before: '2026-04-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries with limit', async () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await store.append(makeEvent());
|
||||||
|
}
|
||||||
|
const results = await store.query({ limit: 3 });
|
||||||
|
expect(results).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all events', async () => {
|
||||||
|
await store.append(makeEvent());
|
||||||
|
await store.append(makeEvent());
|
||||||
|
await store.clear();
|
||||||
|
expect(await store.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for non-existent file', async () => {
|
||||||
|
const fresh = new FileEventStore({
|
||||||
|
filePath: join(tmpdir(), 'nonexistent-' + Date.now() + '.jsonl'),
|
||||||
|
});
|
||||||
|
expect(await fresh.count()).toBe(0);
|
||||||
|
expect(await fresh.recent()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates parent directory if needed', async () => {
|
||||||
|
const nested = join(tmpdir(), `nested-${Date.now()}`, 'sub', 'events.jsonl');
|
||||||
|
const nestedStore = new FileEventStore({ filePath: nested });
|
||||||
|
await nestedStore.append(makeEvent());
|
||||||
|
expect(await nestedStore.count()).toBe(1);
|
||||||
|
await rm(join(tmpdir(), `nested-${Date.now()}`), { recursive: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
162
packages/fastify-sse/src/hub.test.ts
Normal file
162
packages/fastify-sse/src/hub.test.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { SSEHub } from './hub.js';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
function mockResponse(): ServerResponse {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const res = Object.assign(emitter, {
|
||||||
|
writeHead: vi.fn(),
|
||||||
|
write: vi.fn((chunk: string) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
end: vi.fn(),
|
||||||
|
_chunks: chunks,
|
||||||
|
});
|
||||||
|
return res as unknown as ServerResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SSEHub', () => {
|
||||||
|
let hub: SSEHub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hub = new SSEHub();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addClient', () => {
|
||||||
|
it('returns a client ID and sets SSE headers', () => {
|
||||||
|
const res = mockResponse();
|
||||||
|
const id = hub.addClient(res);
|
||||||
|
expect(id).toMatch(/^sse_/);
|
||||||
|
expect(res.writeHead).toHaveBeenCalledWith(
|
||||||
|
200,
|
||||||
|
expect.objectContaining({
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(hub.clientCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends initial connected event', () => {
|
||||||
|
const res = mockResponse();
|
||||||
|
hub.addClient(res);
|
||||||
|
expect(res.write).toHaveBeenCalledWith(expect.stringContaining('event: connected'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes client on close', () => {
|
||||||
|
const res = mockResponse();
|
||||||
|
hub.addClient(res);
|
||||||
|
expect(hub.clientCount).toBe(1);
|
||||||
|
res.emit('close');
|
||||||
|
expect(hub.clientCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('broadcast', () => {
|
||||||
|
it('sends to all connected clients', () => {
|
||||||
|
const res1 = mockResponse();
|
||||||
|
const res2 = mockResponse();
|
||||||
|
hub.addClient(res1);
|
||||||
|
hub.addClient(res2);
|
||||||
|
|
||||||
|
const sent = hub.broadcast({ event: 'test', data: '{"hello":true}' });
|
||||||
|
expect(sent).toBe(2);
|
||||||
|
// Each res gets: initial connected write + broadcast write = 2 calls
|
||||||
|
expect(res1.write).toHaveBeenCalledTimes(2);
|
||||||
|
expect(res2.write).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when no clients', () => {
|
||||||
|
const sent = hub.broadcast({ data: 'test' });
|
||||||
|
expect(sent).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes clients that throw on write', () => {
|
||||||
|
const res1 = mockResponse();
|
||||||
|
const res2 = mockResponse();
|
||||||
|
hub.addClient(res1);
|
||||||
|
hub.addClient(res2);
|
||||||
|
|
||||||
|
// Make res1 throw on next write
|
||||||
|
(res1.write as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||||
|
throw new Error('broken');
|
||||||
|
});
|
||||||
|
|
||||||
|
hub.broadcast({ data: 'test' });
|
||||||
|
// res1 should be removed after the error
|
||||||
|
expect(hub.clientCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendToUser', () => {
|
||||||
|
it('sends only to matching userId', () => {
|
||||||
|
const res1 = mockResponse();
|
||||||
|
const res2 = mockResponse();
|
||||||
|
hub.addClient(res1, 'user-a');
|
||||||
|
hub.addClient(res2, 'user-b');
|
||||||
|
|
||||||
|
const sent = hub.sendToUser('user-a', { data: 'targeted' });
|
||||||
|
expect(sent).toBe(1);
|
||||||
|
// res1: connected + targeted = 2 writes
|
||||||
|
expect(res1.write).toHaveBeenCalledTimes(2);
|
||||||
|
// res2: only connected = 1 write
|
||||||
|
expect(res2.write).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when no matching users', () => {
|
||||||
|
const res = mockResponse();
|
||||||
|
hub.addClient(res, 'user-a');
|
||||||
|
const sent = hub.sendToUser('user-z', { data: 'nothing' });
|
||||||
|
expect(sent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('heartbeat', () => {
|
||||||
|
it('sends comment to all clients', () => {
|
||||||
|
const res = mockResponse();
|
||||||
|
hub.addClient(res);
|
||||||
|
hub.heartbeat();
|
||||||
|
expect(res.write).toHaveBeenCalledWith(': heartbeat\n\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disconnectAll', () => {
|
||||||
|
it('ends all client responses and clears', () => {
|
||||||
|
const res1 = mockResponse();
|
||||||
|
const res2 = mockResponse();
|
||||||
|
hub.addClient(res1);
|
||||||
|
hub.addClient(res2);
|
||||||
|
expect(hub.clientCount).toBe(2);
|
||||||
|
|
||||||
|
hub.disconnectAll();
|
||||||
|
expect(hub.clientCount).toBe(0);
|
||||||
|
expect(res1.end).toHaveBeenCalled();
|
||||||
|
expect(res2.end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatSSE', () => {
|
||||||
|
it('formats with event, id, and data', () => {
|
||||||
|
const res = mockResponse();
|
||||||
|
hub.addClient(res);
|
||||||
|
hub.broadcast({ event: 'task.created', data: '{}', id: 'evt_1' });
|
||||||
|
|
||||||
|
const lastWrite = (res.write as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string;
|
||||||
|
expect(lastWrite).toContain('id: evt_1');
|
||||||
|
expect(lastWrite).toContain('event: task.created');
|
||||||
|
expect(lastWrite).toContain('data: {}');
|
||||||
|
expect(lastWrite).toMatch(/\n\n$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats with retry field', () => {
|
||||||
|
const res = mockResponse();
|
||||||
|
hub.addClient(res);
|
||||||
|
hub.broadcast({ data: 'test', retry: 5000 });
|
||||||
|
|
||||||
|
const lastWrite = (res.write as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string;
|
||||||
|
expect(lastWrite).toContain('retry: 5000');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
17
products/notelett/product.json
Normal file
17
products/notelett/product.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"productId": "notelett",
|
||||||
|
"displayName": "NoteLett",
|
||||||
|
"licensePrefix": "NOTELETT",
|
||||||
|
"configDirName": ".NoteLett",
|
||||||
|
"envVarPrefix": "NOTELETT",
|
||||||
|
"bundleIdSuffix": "notelett",
|
||||||
|
"packageName": "notelett",
|
||||||
|
"domain": "notelett.app",
|
||||||
|
"bundleId": {
|
||||||
|
"ios": "com.bytelyst.notelett",
|
||||||
|
"android": "com.notelett.app"
|
||||||
|
},
|
||||||
|
"appGroup": "group.com.bytelyst.notelett",
|
||||||
|
"backendPort": 4016,
|
||||||
|
"repo": "learning_ai_notes"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user