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 { emitDeployCompleted, emitDeployFailed, emitDeployStarted, emitHealthCheckFailed, emitSystemCreated, emitSystemDestroyed, emitUninstallCompleted, emitUninstallFailed, emitUninstallStarted, emitWebRoutesChanged, emitWebRoutesChangedAndWait, routesChangedHighWater, waitForRouteReconcile, } from './celilo-events'; describe('celilo lifecycle events', () => { let dir: string; let dbPath: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-events-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 */ } }); function recentEvents() { const bus = openBus({ dbPath, events: defineEvents({}) }); try { return bus.recentEvents({ limit: 100 }); } finally { bus.close(); } } it('emits deploy.started. with expected payload', () => { emitDeployStarted({ module: 'lunacycle', startedAt: 1234 }); const events = recentEvents(); expect(events).toHaveLength(1); expect(events[0].type).toBe('deploy.started.lunacycle'); expect(events[0].payload).toEqual({ module: 'lunacycle', startedAt: 1234 }); }); it('emits deploy.completed. with duration', () => { emitDeployCompleted({ module: 'authentik', startedAt: 1000, durationMs: 5500 }); const events = recentEvents(); expect(events[0].type).toBe('deploy.completed.authentik'); expect((events[0].payload as { durationMs: number }).durationMs).toBe(5500); }); it('emits deploy.failed. with error', () => { emitDeployFailed({ module: 'authentik', startedAt: 1000, durationMs: 200, error: 'oops', }); const events = recentEvents(); expect(events[0].type).toBe('deploy.failed.authentik'); expect((events[0].payload as { error: string }).error).toBe('oops'); }); it('emits health-check.failed.', () => { emitHealthCheckFailed({ module: 'lunacycle', reason: 'http 503' }); const events = recentEvents(); expect(events[0].type).toBe('health-check.failed.lunacycle'); }); it('emits uninstall.started.', () => { emitUninstallStarted({ module: 'caddy', startedAt: 2000 }); const events = recentEvents(); expect(events).toHaveLength(1); expect(events[0].type).toBe('uninstall.started.caddy'); expect(events[0].payload).toEqual({ module: 'caddy', startedAt: 2000 }); }); it('emits uninstall.completed. with duration', () => { emitUninstallCompleted({ module: 'caddy', startedAt: 2000, durationMs: 1500 }); const events = recentEvents(); expect(events[0].type).toBe('uninstall.completed.caddy'); expect((events[0].payload as { durationMs: number }).durationMs).toBe(1500); }); it('emits uninstall.failed. with error', () => { emitUninstallFailed({ module: 'caddy', startedAt: 2000, durationMs: 300, error: 'terraform destroy failed', }); const events = recentEvents(); expect(events[0].type).toBe('uninstall.failed.caddy'); expect((events[0].payload as { error: string }).error).toBe('terraform destroy failed'); }); it('emits system.created. with host identity', () => { emitSystemCreated({ module: 'technitium', hostname: 'dns-int', targetIp: '192.168.0.53' }); const events = recentEvents(); expect(events).toHaveLength(1); expect(events[0].type).toBe('system.created.technitium'); expect(events[0].payload).toEqual({ module: 'technitium', hostname: 'dns-int', targetIp: '192.168.0.53', }); }); it('emits system.destroyed. with host identity', () => { emitSystemDestroyed({ module: 'caddy', hostname: 'www', targetIp: '10.0.10.10' }); const events = recentEvents(); expect(events[0].type).toBe('system.destroyed.caddy'); expect((events[0].payload as { hostname: string }).hostname).toBe('www'); }); it('system.created.* subscriber receives a delivery (the D5 fan-out path)', () => { const setupBus = openBus({ dbPath, events: defineEvents({}) }); setupBus.subscribe({ name: 'dns-internal.auto-register', pattern: 'system.created.*', handler: 'unused', }); setupBus.close(); emitSystemCreated({ module: 'lunacycle', hostname: 'lunacycle', targetIp: '10.0.20.11' }); const bus = openBus({ dbPath, events: defineEvents({}) }); try { expect(bus.pendingDeliveries()).toHaveLength(1); } finally { bus.close(); } }); it('triggers persistent subscribers and creates deliveries', () => { // Pre-register a subscriber that targets all completed deploys. const setupBus = openBus({ dbPath, events: defineEvents({}) }); setupBus.subscribe({ name: 'test-watcher', pattern: 'deploy.completed.*', handler: 'unused', }); setupBus.close(); emitDeployCompleted({ module: 'lunacycle', startedAt: 1, durationMs: 2 }); const bus = openBus({ dbPath, events: defineEvents({}) }); try { const pending = bus.pendingDeliveries(); expect(pending).toHaveLength(1); } finally { bus.close(); } }); it('does not throw when the bus path is unwritable', () => { process.env.EVENT_BUS_DB = '/proc/no/such/place/events.db'; expect(() => emitDeployStarted({ module: 'x', startedAt: 0 })).not.toThrow(); }); describe('deploy-waits for web-route reconcile (ISS-0035)', () => { function subscribeReconciler(): void { const bus = openBus({ dbPath, events: defineEvents({}) }); bus.subscribe({ name: 'caddy.reconcile-web-routes', pattern: 'public_web.routes_changed', handler: 'unused', }); bus.close(); } /** Stamp a fresh dispatcher heartbeat so health() reports a live dispatcher. */ function markDispatcherAlive(): void { const bus = openBus({ dbPath, events: defineEvents({}) }); bus.db.run( `INSERT OR REPLACE INTO dispatcher_heartbeat (dispatcher_id, last_heartbeat, started_at, pid, version) VALUES ('test', ?, ?, 1, '0.1.0')`, [Date.now(), Date.now()], ); bus.close(); } function settleDelivery(how: 'succeed' | 'abandon'): void { const bus = openBus({ dbPath, events: defineEvents({}) }); const sub = bus.getSubscriberByName('caddy.reconcile-web-routes'); const event = bus.recentEvents({ type: 'public_web.routes_changed', limit: 1 })[0]; if (!sub || !event) { throw new Error('expected a subscriber and an emitted event'); } if (how === 'succeed') { bus.markSucceeded({ eventId: event.id, subscriberId: sub.id }); } else { bus.markFailed({ eventId: event.id, subscriberId: sub.id }, 'boom', { abandoned: true }); } bus.close(); } it('routesChangedHighWater is 0 with no events, then the latest id', () => { expect(routesChangedHighWater()).toBe(0); emitWebRoutesChanged('celilo-website'); expect(routesChangedHighWater()).toBeGreaterThan(0); }); it('returns immediately when the deploy changed no routes', async () => { const since = routesChangedHighWater(); const result = await waitForRouteReconcile(since, { timeoutMs: 1000 }); expect(result).toEqual({ events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false, }); }); it('skips the wait (no time-out) when no dispatcher is running', async () => { subscribeReconciler(); const since = routesChangedHighWater(); emitWebRoutesChanged('celilo-website'); // No heartbeat stamped → health() is no_dispatcher. const result = await waitForRouteReconcile(since, { timeoutMs: 5000, pollMs: 50 }); expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false, noDispatcher: true, }); }); it('settles immediately when no provider subscribes (zero deliveries)', async () => { markDispatcherAlive(); const since = routesChangedHighWater(); emitWebRoutesChanged('celilo-website'); const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 }); expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false, }); }); it('times out while the provider delivery is still pending', async () => { markDispatcherAlive(); subscribeReconciler(); const since = routesChangedHighWater(); emitWebRoutesChanged('celilo-website'); const result = await waitForRouteReconcile(since, { timeoutMs: 300, pollMs: 50 }); expect(result.events).toBe(1); expect(result.timedOut).toBe(true); }); it('completes once the provider delivery succeeds', async () => { markDispatcherAlive(); subscribeReconciler(); const since = routesChangedHighWater(); emitWebRoutesChanged('celilo-website'); settleDelivery('succeed'); const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 }); expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false, noDispatcher: false, }); }); it('reports a failed delivery without timing out', async () => { markDispatcherAlive(); subscribeReconciler(); const since = routesChangedHighWater(); emitWebRoutesChanged('celilo-website'); settleDelivery('abandon'); const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 }); expect(result.events).toBe(1); expect(result.failed).toBe(1); expect(result.timedOut).toBe(false); }); it('emitWebRoutesChangedAndWait emits then waits for the reconcile to settle', async () => { markDispatcherAlive(); subscribeReconciler(); // Settle the delivery concurrently, mid-wait, the way the dispatcher would. const pending = emitWebRoutesChangedAndWait('celilo-website', { timeoutMs: 2000, pollMs: 50, }); await new Promise((r) => setTimeout(r, 120)); settleDelivery('succeed'); const result = await pending; expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false, noDispatcher: false, }); }); }); });