import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { defineEvents, openBus } from '@celilo/event-bus'; import { type FailedDeliveryReport, handleEventsAck, handleEventsDrain, handleEventsEmit, handleEventsFail, handleEventsListFailed, handleEventsListPending, handleEventsListSubscribers, handleEventsRepair, handleEventsReply, handleEventsStatus, handleEventsTail, } from './events'; describe('celilo events command handlers', () => { let dir: string; let dbPath: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'events-cmd-test-')); dbPath = join(dir, 'events.db'); process.env.EVENT_BUS_DB = dbPath; }); afterEach(() => { delete process.env.EVENT_BUS_DB; try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('status reports no_dispatcher on a fresh bus', async () => { const result = await handleEventsStatus(); expect(result.success).toBe(true); if (!result.success) throw new Error('expected success'); const data = result.data as { status: string }; expect(data.status).toBe('no_dispatcher'); }); it('emit + tail roundtrips an event', async () => { const emit = await handleEventsEmit(['custom.event.foo', '{"x":1}'], { 'emitted-by': 'test' }); expect(emit.success).toBe(true); const tail = await handleEventsTail([], { limit: '10' }); expect(tail.success).toBe(true); if (!tail.success) throw new Error('expected success'); const events = tail.data as Array<{ type: string; payload: unknown }>; expect(events).toHaveLength(1); expect(events[0].type).toBe('custom.event.foo'); expect(events[0].payload).toEqual({ x: 1 }); }); it('list-subscribers and list-pending reflect bus state', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); setupBus.subscribe({ name: 'test-sub', pattern: 'foo.*', handler: 'echo' }); setupBus.emitRaw('foo.bar', { v: 1 }); setupBus.close(); const subs = await handleEventsListSubscribers(); expect(subs.success).toBe(true); if (!subs.success) throw new Error('expected success'); expect((subs.data as { name: string }[])[0].name).toBe('test-sub'); const pending = await handleEventsListPending([], {}); expect(pending.success).toBe(true); if (!pending.success) throw new Error('expected success'); expect(pending.data as unknown[]).toHaveLength(1); }); it('drain on an empty queue returns processed: 0', async () => { // The CLI drain path opens its own bus, so it can't reach an in-process // onEvent registered on a separate bus instance. Spawn-based handler // exercise lives in the bus library's own integration tests; here we // assert the CLI plumbing returns the bus result shape. const result = await handleEventsDrain([], {}); expect(result.success).toBe(true); if (!result.success) throw new Error('expected success'); expect((result.data as { processed: number }).processed).toBe(0); }); it('ack marks a running delivery succeeded', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); setupBus.subscribe({ name: 's', pattern: 'foo', handler: 'echo' }); setupBus.emitRaw('foo', {}); // Force delivery to running state as if the dispatcher had claimed it. setupBus.db.run("UPDATE deliveries SET status = 'running', started_at = ?, attempts = 1", [ Date.now(), ]); setupBus.close(); const ack = await handleEventsAck(['1'], {}); expect(ack.success).toBe(true); const verify = openBus({ dbPath, events: defineEvents({}) }); try { const row = verify.db.query<{ status: string }, []>('SELECT status FROM deliveries').get(); expect(row?.status).toBe('succeeded'); } finally { verify.close(); } }); it('fail with --no-retry abandons the delivery', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); setupBus.subscribe({ name: 's', pattern: 'foo', handler: 'echo' }); setupBus.emitRaw('foo', {}); setupBus.db.run("UPDATE deliveries SET status = 'running', started_at = ?, attempts = 3", [ Date.now(), ]); setupBus.close(); const result = await handleEventsFail(['1'], { error: 'bad', 'no-retry': true }); expect(result.success).toBe(true); const verify = openBus({ dbPath, events: defineEvents({}) }); try { const row = verify.db.query<{ status: string }, []>('SELECT status FROM deliveries').get(); expect(row?.status).toBe('abandoned'); } finally { verify.close(); } }); it('ack errors out with no running delivery and no --subscriber', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); setupBus.emitRaw('orphan', {}); setupBus.close(); const result = await handleEventsAck(['1'], {}); expect(result.success).toBe(false); }); it('repair runs the recovery sweep', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); setupBus.subscribe({ name: 's', pattern: 'foo', handler: 'echo', timeoutMs: 100 }); setupBus.emitRaw('foo', {}); const ancient = Date.now() - 60_000; setupBus.db.run("UPDATE deliveries SET status = 'running', started_at = ?, attempts = 1", [ ancient, ]); setupBus.db.run( `INSERT INTO dispatcher_heartbeat (dispatcher_id, last_heartbeat, started_at, pid, version) VALUES (?, ?, ?, ?, ?)`, ['ghost', ancient, ancient, 99999, '0.0.0'], ); setupBus.close(); const result = await handleEventsRepair(); expect(result.success).toBe(true); if (!result.success) throw new Error('expected success'); expect((result.data as { recovered: boolean }).recovered).toBe(true); expect((result.data as { stuckCount: number }).stuckCount).toBe(1); }); it('reply answers a config.required query with a correlated reply', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); const query = setupBus.emitRaw('config.required.lunacycle.domain', { module: 'lunacycle', key: 'domain', type: 'string', required: true, }); setupBus.close(); const res = await handleEventsReply([String(query.id), '"example.net"'], {}); expect(res.success).toBe(true); if (!res.success) throw new Error('expected success'); const data = res.data as { status: string; family: string; value: unknown }; expect(data.status).toBe('replied'); expect(data.family).toBe('config'); expect(data.value).toBe('example.net'); const checkBus = openBus({ dbPath, events: defineEvents({}) }); const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' }); checkBus.close(); expect(replies).toHaveLength(1); expect(replies[0].replyFor).toBe(query.id); expect(replies[0].payload).toEqual({ value: 'example.net' }); expect(replies[0].emittedBy).toBe('claude-config-responder'); }); it('reply honors --emitted-by for the audit identity', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); const query = setupBus.emitRaw('config.required.caddy.acme_email', { module: 'caddy', key: 'acme_email', type: 'string', required: true, }); setupBus.close(); await handleEventsReply([String(query.id), '"a@b.com"'], { 'emitted-by': 'operator-x' }); const checkBus = openBus({ dbPath, events: defineEvents({}) }); const replies = checkBus.recentEvents({ type: 'config.required.caddy.acme_email.reply' }); checkBus.close(); expect(replies[0].emittedBy).toBe('operator-x'); }); it('reply on an unknown event id fails clearly', async () => { const res = await handleEventsReply(['99999', '"x"'], {}); expect(res.success).toBe(false); if (res.success) throw new Error('expected failure'); expect(res.error).toContain('No event with id 99999'); }); it('reply on a non-interview event fails clearly', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); const event = setupBus.emitRaw('custom.thing', { x: 1 }); setupBus.close(); const res = await handleEventsReply([String(event.id), '"x"'], {}); expect(res.success).toBe(false); if (res.success) throw new Error('expected failure'); expect(res.error).toContain('not an interview query'); }); it('reply rejects a non-JSON value', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); const query = setupBus.emitRaw('config.required.lunacycle.domain', { module: 'lunacycle', key: 'domain', type: 'string', required: true, }); setupBus.close(); // A bare word isn't valid JSON — the operator must quote strings. const res = await handleEventsReply([String(query.id), 'example.net'], {}); expect(res.success).toBe(false); if (res.success) throw new Error('expected failure'); expect(res.error).toContain('Invalid JSON value'); }); it('reply is idempotent — a second reply reports already-answered and does not re-emit', async () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); const query = setupBus.emitRaw('config.required.lunacycle.domain', { module: 'lunacycle', key: 'domain', type: 'string', required: true, }); setupBus.close(); const first = await handleEventsReply([String(query.id), '"a.net"'], {}); expect(first.success).toBe(true); if (!first.success) throw new Error('expected success'); expect((first.data as { status: string }).status).toBe('replied'); const second = await handleEventsReply([String(query.id), '"b.net"'], {}); expect(second.success).toBe(true); if (!second.success) throw new Error('expected success'); expect((second.data as { status: string }).status).toBe('already-answered'); const checkBus = openBus({ dbPath, events: defineEvents({}) }); const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' }); checkBus.close(); expect(replies).toHaveLength(1); expect(replies[0].payload).toEqual({ value: 'a.net' }); }); }); describe('celilo events list-failed', () => { let dir: string; let dbPath: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'events-failed-test-')); dbPath = join(dir, 'events.db'); process.env.EVENT_BUS_DB = dbPath; const bus = openBus({ dbPath, events: defineEvents({}) }); const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.*', handler: 'echo' }); for (let i = 0; i < 60; i++) { const event = bus.emitRaw('ddns.refresh', { n: i }); bus.markFailed({ eventId: event.id, subscriberId: sub.id }, new Error('boom'), { abandoned: true, }); } bus.close(); }); afterEach(() => { delete process.env.EVENT_BUS_DB; try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); // The whole point of the command: `total` is a COUNT, `shown` is the limit. it('separates the true total from the capped sample', async () => { const result = await handleEventsListFailed([], { limit: '5' }); expect(result.success).toBe(true); if (!result.success) throw new Error('expected success'); const report = result.data as FailedDeliveryReport; expect(report.total).toBe(60); expect(report.shown).toBe(5); expect(report.deliveries).toHaveLength(5); expect(report.bySubscriber).toEqual([ { subscriber: 'namecheap.ddns', count: 60, latestFinishedAt: expect.any(Number) }, ]); }); it('names the subscriber and event type on every row', async () => { const result = await handleEventsListFailed([], { limit: '1' }); if (!result.success) throw new Error('expected success'); const row = (result.data as FailedDeliveryReport).deliveries[0]; expect(row.subscriber).toBe('namecheap.ddns'); expect(row.eventType).toBe('ddns.refresh'); expect(row.status).toBe('abandoned'); expect(row.error).toBe('boom'); expect(row.finishedAt).toBeGreaterThan(0); }); it('--subscriber scopes both the rows and the total', async () => { const mine = await handleEventsListFailed([], { subscriber: 'namecheap.ddns', limit: '3' }); if (!mine.success) throw new Error('expected success'); expect((mine.data as FailedDeliveryReport).total).toBe(60); const other = await handleEventsListFailed([], { subscriber: 'nobody' }); if (!other.success) throw new Error('expected success'); expect((other.data as FailedDeliveryReport).total).toBe(0); expect((other.data as FailedDeliveryReport).deliveries).toHaveLength(0); }); });