diff --git a/packages/event-store/src/file-store.test.ts b/packages/event-store/src/file-store.test.ts new file mode 100644 index 00000000..1ee18104 --- /dev/null +++ b/packages/event-store/src/file-store.test.ts @@ -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 { + 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(() => {}); + }); +}); diff --git a/packages/fastify-sse/src/hub.test.ts b/packages/fastify-sse/src/hub.test.ts new file mode 100644 index 00000000..fbfccb7e --- /dev/null +++ b/packages/fastify-sse/src/hub.test.ts @@ -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).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).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).mock.calls.at(-1)?.[0] as string; + expect(lastWrite).toContain('retry: 5000'); + }); + }); +}); diff --git a/products/notelett/product.json b/products/notelett/product.json new file mode 100644 index 00000000..85e0551e --- /dev/null +++ b/products/notelett/product.json @@ -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" +}