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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
162
services/api/src/events/__tests__/nats.test.ts
Normal file
162
services/api/src/events/__tests__/nats.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Tests for the NATS JetStream adapter (events/nats.ts).
|
||||
*
|
||||
* The real `nats` module is mocked so we can verify:
|
||||
* - streams are created when missing, skipped when present
|
||||
* - bus.onPublish() bridge encodes payload as JSON and calls js.publish
|
||||
* - bridge errors are caught (do not crash the in-process publisher)
|
||||
* - a connection failure logs a warning and leaves the bus working
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── nats mock ────────────────────────────────────────────────────────────────
|
||||
// Capture the most recent fakes so individual tests can inspect them.
|
||||
let lastConnect: ReturnType<typeof vi.fn>;
|
||||
let lastJsPublish: ReturnType<typeof vi.fn>;
|
||||
let lastStreamsAdd: ReturnType<typeof vi.fn>;
|
||||
let lastStreamsInfo: ReturnType<typeof vi.fn>;
|
||||
let lastDrain: ReturnType<typeof vi.fn>;
|
||||
|
||||
vi.mock('nats', () => {
|
||||
const StorageType = { File: 'file', Memory: 'memory' };
|
||||
const RetentionPolicy = { Limits: 'limits', Workqueue: 'workqueue', Interest: 'interest' };
|
||||
const DiscardPolicy = { Old: 'old', New: 'new' };
|
||||
|
||||
const connect = vi.fn(async (_opts: unknown) => {
|
||||
lastJsPublish = vi.fn(async () => ({ seq: 1 }));
|
||||
lastStreamsAdd = vi.fn(async () => ({}));
|
||||
lastStreamsInfo = vi.fn(async (_name: string) => {
|
||||
throw new Error('stream not found');
|
||||
});
|
||||
lastDrain = vi.fn(async () => {});
|
||||
|
||||
const jsm = { streams: { info: lastStreamsInfo, add: lastStreamsAdd } };
|
||||
return {
|
||||
jetstream: () => ({ publish: lastJsPublish }),
|
||||
jetstreamManager: async () => jsm,
|
||||
drain: lastDrain,
|
||||
};
|
||||
});
|
||||
|
||||
lastConnect = connect;
|
||||
return { connect, StorageType, RetentionPolicy, DiscardPolicy };
|
||||
});
|
||||
|
||||
import { Bus } from '../bus.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('connectNats — happy path', () => {
|
||||
it('creates both streams when neither exists', async () => {
|
||||
// Re-import to get a fresh module state; nats is hoisted-mocked above.
|
||||
const { connectNats } = await import('../nats.js');
|
||||
await connectNats('nats://test:4222');
|
||||
|
||||
expect(lastConnect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ servers: 'nats://test:4222', reconnect: true }),
|
||||
);
|
||||
expect(lastStreamsInfo).toHaveBeenCalledTimes(2);
|
||||
expect(lastStreamsAdd).toHaveBeenCalledTimes(2);
|
||||
|
||||
const created = lastStreamsAdd.mock.calls.map((c) => (c[0] as any).name).sort();
|
||||
expect(created).toEqual(['feedback', 'signals']);
|
||||
|
||||
const signalsCfg = lastStreamsAdd.mock.calls
|
||||
.map((c) => c[0] as any)
|
||||
.find((c) => c.name === 'signals');
|
||||
expect(signalsCfg.subjects).toEqual(['signals.>']);
|
||||
expect(signalsCfg.storage).toBe('file');
|
||||
expect(signalsCfg.retention).toBe('limits');
|
||||
});
|
||||
|
||||
it('skips stream creation when info() succeeds (idempotent)', async () => {
|
||||
const { connectNats } = await import('../nats.js');
|
||||
// Override info to succeed.
|
||||
lastConnect.mockImplementationOnce(async () => {
|
||||
lastJsPublish = vi.fn(async () => ({ seq: 1 }));
|
||||
lastStreamsAdd = vi.fn(async () => ({}));
|
||||
lastStreamsInfo = vi.fn(async (_name: string) => ({ config: { name: _name } }));
|
||||
lastDrain = vi.fn(async () => {});
|
||||
return {
|
||||
jetstream: () => ({ publish: lastJsPublish }),
|
||||
jetstreamManager: async () => ({
|
||||
streams: { info: lastStreamsInfo, add: lastStreamsAdd },
|
||||
}),
|
||||
drain: lastDrain,
|
||||
};
|
||||
});
|
||||
|
||||
await connectNats('nats://test:4222');
|
||||
expect(lastStreamsInfo).toHaveBeenCalledTimes(2);
|
||||
expect(lastStreamsAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectNats — bridge bus → JetStream', () => {
|
||||
it('publishes JSON-encoded payload on the same subject', async () => {
|
||||
const { connectNats } = await import('../nats.js');
|
||||
const { bus } = await import('../bus.js');
|
||||
|
||||
await connectNats('nats://test:4222');
|
||||
|
||||
const payload = { userId: 'u1', count: 7, syncedAt: '2026-01-01T00:00:00Z' };
|
||||
bus.publish('signals.task.synced', payload);
|
||||
|
||||
// Allow the queued microtask in the hook to flush.
|
||||
await Promise.resolve();
|
||||
|
||||
expect(lastJsPublish).toHaveBeenCalledTimes(1);
|
||||
const [subject, data] = lastJsPublish.mock.calls[0];
|
||||
expect(subject).toBe('signals.task.synced');
|
||||
const decoded = JSON.parse(new TextDecoder().decode(data as Uint8Array));
|
||||
expect(decoded).toEqual(payload);
|
||||
});
|
||||
|
||||
it('swallows JetStream publish errors so the in-process bus keeps working', async () => {
|
||||
const { connectNats } = await import('../nats.js');
|
||||
const { bus } = await import('../bus.js');
|
||||
|
||||
await connectNats('nats://test:4222');
|
||||
|
||||
// Force the next js.publish to reject.
|
||||
lastJsPublish.mockRejectedValueOnce(new Error('jetstream down'));
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() =>
|
||||
bus.publish('signals.task.synced', { userId: 'u', count: 0, syncedAt: '' }),
|
||||
).not.toThrow();
|
||||
|
||||
// Wait a tick for the rejected promise's catch to run.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectNats — failure mode', () => {
|
||||
it('logs a warning and stays silent when connect rejects', async () => {
|
||||
const { connectNats } = await import('../nats.js');
|
||||
|
||||
lastConnect.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await expect(connectNats('nats://nope:4222')).resolves.toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('connection failed'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bus.onPublish contract — used by NATS bridge', () => {
|
||||
it('a fresh Bus accepts and fires onPublish hooks (smoke check)', () => {
|
||||
const b = new Bus();
|
||||
const hook = vi.fn();
|
||||
b.onPublish(hook);
|
||||
b.publish('signals.task.synced', { userId: 'u', count: 0, syncedAt: '' });
|
||||
expect(hook).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user