/** * Status-aggregator tests — Phase 6 of [[openspec/changes/build-bus-poll-cd/proposal.md]]. * * The aggregator is pure: takes subscribers + delivery records, * returns per-subscriber summaries. The formatter is pure too. * Both tested directly here without spinning up a real bus or * publishing anything. */ import { describe, expect, test } from 'bun:test'; import type { Subscriber } from '@celilo/event-bus/build-bus'; import { WEBHOOK_DELIVERED_EVENT, WEBHOOK_FAILED_EVENT, type WebhookDeliveryPayload, } from './delivery-events'; import { type DeliveryRecord, aggregateSubscriberStatus, describeAgo, formatStatus, } from './status'; function sub(overrides: Partial): Subscriber { return { url: 'https://example.test/webhook', secret: 'shared', match: {}, ...overrides, }; } function record( type: typeof WEBHOOK_DELIVERED_EVENT | typeof WEBHOOK_FAILED_EVENT, payloadOverrides: Partial, emittedAt: number, ): DeliveryRecord { return { type, emittedAt, payload: { subscriberLabel: 'test-sub', subscriberUrl: 'https://example.test/webhook', eventId: 'evt-0', packageName: '@celilo/cli', packageVersion: '0.4.0', tag: 'latest', attempts: 1, durationMs: 42, ...payloadOverrides, }, }; } describe('aggregateSubscriberStatus', () => { test('subscriber with no deliveries → totals 0, successRate null, no lastDelivery', () => { const [s] = aggregateSubscriberStatus([sub({ name: 'a' })], []); expect(s.totals).toEqual({ delivered: 0, failed: 0 }); expect(s.successRatePct).toBeNull(); expect(s.lastDelivery).toBeUndefined(); expect(s.recentFailures).toEqual([]); }); test('all deliveries succeed → 100% success', () => { const subscribers = [sub({ name: 'a' })]; const records = [ record(WEBHOOK_DELIVERED_EVENT, {}, 1000), record(WEBHOOK_DELIVERED_EVENT, {}, 2000), record(WEBHOOK_DELIVERED_EVENT, {}, 3000), ]; const [s] = aggregateSubscriberStatus(subscribers, records); expect(s.totals).toEqual({ delivered: 3, failed: 0 }); expect(s.successRatePct).toBe(100); }); test('mixed success/failure → integer success rate', () => { const subscribers = [sub({ name: 'a' })]; const records = [ record(WEBHOOK_DELIVERED_EVENT, {}, 1000), record(WEBHOOK_FAILED_EVENT, { error: 'boom' }, 2000), record(WEBHOOK_DELIVERED_EVENT, {}, 3000), ]; const [s] = aggregateSubscriberStatus(subscribers, records); expect(s.totals).toEqual({ delivered: 2, failed: 1 }); // 2/3 = 66.66… → rounded to 67% expect(s.successRatePct).toBe(67); }); test('lastDelivery is the newest by emittedAt (success or failure)', () => { const subscribers = [sub({ name: 'a' })]; const records = [ record(WEBHOOK_DELIVERED_EVENT, { eventId: 'old' }, 1000), record(WEBHOOK_FAILED_EVENT, { eventId: 'newest', error: 'x' }, 9000), record(WEBHOOK_DELIVERED_EVENT, { eventId: 'middle' }, 5000), ]; const [s] = aggregateSubscriberStatus(subscribers, records); expect(s.lastDelivery?.eventId).toBe('newest'); expect(s.lastDelivery?.ok).toBe(false); }); test('recentFailures are newest-first and capped at 5', () => { const subscribers = [sub({ name: 'a' })]; const records: DeliveryRecord[] = []; for (let i = 0; i < 8; i++) { records.push(record(WEBHOOK_FAILED_EVENT, { eventId: `f-${i}`, error: `e-${i}` }, i * 1000)); } const [s] = aggregateSubscriberStatus(subscribers, records); expect(s.recentFailures).toHaveLength(5); // Newest first. expect(s.recentFailures[0].eventId).toBe('f-7'); expect(s.recentFailures[4].eventId).toBe('f-3'); }); test('error strings are truncated to keep the status output readable', () => { const longError = 'X'.repeat(500); const subscribers = [sub({ name: 'a' })]; const records = [record(WEBHOOK_FAILED_EVENT, { error: longError }, 1000)]; const [s] = aggregateSubscriberStatus(subscribers, records); expect(s.recentFailures[0].error.length).toBeLessThanOrEqual(200); }); test('groups by subscriberUrl (subscribers whose URLs do not match get no records)', () => { const subscribers = [ sub({ name: 'a', url: 'https://a.test/' }), sub({ name: 'b', url: 'https://b.test/' }), ]; const records = [ record(WEBHOOK_DELIVERED_EVENT, { subscriberUrl: 'https://a.test/' }, 1000), record(WEBHOOK_DELIVERED_EVENT, { subscriberUrl: 'https://a.test/' }, 2000), record(WEBHOOK_FAILED_EVENT, { subscriberUrl: 'https://b.test/', error: 'oops' }, 1500), ]; const [a, b] = aggregateSubscriberStatus(subscribers, records); expect(a.totals).toEqual({ delivered: 2, failed: 0 }); expect(b.totals).toEqual({ delivered: 0, failed: 1 }); }); test('preserves subscriber input order', () => { const subscribers = [ sub({ name: 'z', url: 'https://z.test/' }), sub({ name: 'a', url: 'https://a.test/' }), sub({ name: 'm', url: 'https://m.test/' }), ]; const result = aggregateSubscriberStatus(subscribers, []); expect(result.map((s) => s.label)).toEqual(['z', 'a', 'm']); }); }); describe('formatStatus', () => { const now = 10_000_000; test('renders a no-subscribers message', () => { expect(formatStatus([], now)).toBe('No subscribers configured.'); }); test('renders no-deliveries-yet for an unused subscriber', () => { const aggregated = aggregateSubscriberStatus([sub({ name: 'fresh' })], []); const out = formatStatus(aggregated, now); expect(out).toContain('deliveries: none yet'); expect(out).not.toContain('last delivery'); }); test('renders success rate + last delivery for an active subscriber', () => { const aggregated = aggregateSubscriberStatus( [sub({ name: 'active' })], [ record( WEBHOOK_DELIVERED_EVENT, { packageName: '@celilo/cli', packageVersion: '0.4.0' }, now - 60_000, ), record(WEBHOOK_DELIVERED_EVENT, {}, now - 300_000), ], ); const out = formatStatus(aggregated, now); expect(out).toContain('deliveries: 2/2 ok (100%)'); expect(out).toContain('last delivery: ✓ @celilo/cli@0.4.0 (1m ago)'); }); test('renders recent-failures block with truncated errors', () => { const aggregated = aggregateSubscriberStatus( [sub({ name: 'flaky' })], [record(WEBHOOK_FAILED_EVENT, { error: 'HTTP 500: backend wedged' }, now - 30_000)], ); const out = formatStatus(aggregated, now); expect(out).toContain('recent failures (1):'); expect(out).toContain('HTTP 500: backend wedged'); expect(out).toContain('30s ago'); }); }); describe('describeAgo', () => { test('seconds', () => { expect(describeAgo(0)).toBe('0s ago'); expect(describeAgo(15_000)).toBe('15s ago'); }); test('minutes', () => { expect(describeAgo(90 * 1000)).toBe('1m ago'); expect(describeAgo(45 * 60 * 1000)).toBe('45m ago'); }); test('hours', () => { expect(describeAgo(2 * 60 * 60 * 1000)).toBe('2h ago'); }); test('days', () => { expect(describeAgo(3 * 24 * 60 * 60 * 1000)).toBe('3d ago'); }); test('future deltas (clock skew) → "in the future"', () => { expect(describeAgo(-500)).toBe('in the future'); }); });