test: cover NATS bridge + Todoist scheduler; ADR-0010

- bus.test.ts: 4 cases for the new onPublish hook contract
- nats.test.ts: stream creation idempotency + JSON publish bridge
- scheduler.test.ts: startup delay, fan-out, per-user failure isolation
- ADR-0010 documents the bridge-don't-replace decision and the
  Todoist scheduler isolation, plus open follow-ups (#98 ml/serving
  consumer, #54 protobuf migration, graceful shutdown, metrics)
- README/overview/services README reflect the bridged event substrate
- CLAUDE.md gains a "don't nats.publish() directly" rule
- .env.example documents NATS_URL + TODOIST_SYNC_INTERVAL_MS

Verified in deployment 2026-04-18: api -> nats bridge connects on
boot, signals + feedback streams created, scheduler tick logs
"todoist sync: 1 ok, 0 failed (1 users)" within 10s. Closes #21, #22.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 07:55:25 +00:00
parent 2a7380933c
commit 5b52c6bf40
9 changed files with 414 additions and 6 deletions

View File

@@ -171,3 +171,51 @@ describe('EventBus — singleton bus export', () => {
expect(bus).toBeInstanceOf(Bus);
});
});
describe('EventBus — onPublish hook (NATS bridge contract)', () => {
it('invokes registered hook with subject + payload on every publish', () => {
const b = makeBus();
const hook = vi.fn();
b.onPublish(hook);
const payload = { userId: 'u', count: 2, syncedAt: 'now' };
b.publish('signals.task.synced', payload);
expect(hook).toHaveBeenCalledOnce();
expect(hook).toHaveBeenCalledWith('signals.task.synced', payload);
});
it('fires multiple hooks in registration order', () => {
const b = makeBus();
const calls: string[] = [];
b.onPublish(() => calls.push('a'));
b.onPublish(() => calls.push('b'));
b.publish('signals.task.synced', { userId: 'u', count: 0, syncedAt: '' });
expect(calls).toEqual(['a', 'b']);
});
it('hook fires alongside subscribers — both receive the publish', () => {
const b = makeBus();
const hook = vi.fn();
const sub = vi.fn();
b.onPublish(hook);
b.subscribe('signals.task.synced', sub);
b.publish('signals.task.synced', { userId: 'u', count: 1, syncedAt: '' });
expect(hook).toHaveBeenCalledOnce();
expect(sub).toHaveBeenCalledOnce();
});
it('a throwing hook still bubbles up — bus does not silently swallow', () => {
// Documents current behaviour: hooks run inside publish(), so a thrown
// error escapes. The NATS adapter therefore catches inside the hook.
const b = makeBus();
b.onPublish(() => {
throw new Error('boom');
});
expect(() =>
b.publish('signals.task.synced', { userId: 'u', count: 0, syncedAt: '' }),
).toThrow('boom');
});
});