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:
130
services/api/src/signals/__tests__/scheduler.test.ts
Normal file
130
services/api/src/signals/__tests__/scheduler.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Tests for the Todoist background sync scheduler (signals/scheduler.ts).
|
||||
*
|
||||
* The scheduler queries every user with an active integration token and calls
|
||||
* todoistSource.fetchSignals(userId) for each. We mock the db and todoistSource
|
||||
* so no network or SQLite is touched; vi.useFakeTimers() drives the scheduler
|
||||
* deterministically.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── mock the drizzle query chain: db.select(...).from(...).where(...) ────────
|
||||
let users: { userId: string }[] = [];
|
||||
const whereMock = vi.fn(async () => users);
|
||||
const fromMock = vi.fn(() => ({ where: whereMock }));
|
||||
const selectMock = vi.fn(() => ({ from: fromMock }));
|
||||
vi.mock('../../db/index.js', () => ({ db: { select: selectMock } }));
|
||||
|
||||
// integrationTokens stub — the scheduler only references column identities
|
||||
vi.mock('../../db/schema.js', () => ({
|
||||
integrationTokens: { userId: { name: 'user_id' }, tokenStatus: { name: 'token_status' } },
|
||||
}));
|
||||
|
||||
// drizzle-orm.eq stub — the scheduler only uses it as a predicate marker
|
||||
vi.mock('drizzle-orm', () => ({ eq: (a: unknown, b: unknown) => ({ a, b }) }));
|
||||
|
||||
// todoistSource.fetchSignals — replaceable per test
|
||||
const fetchSignalsMock = vi.fn(async (_userId: string) => []);
|
||||
vi.mock('../todoist.js', () => ({
|
||||
todoistSource: { fetchSignals: fetchSignalsMock },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
users = [];
|
||||
fetchSignalsMock.mockReset().mockResolvedValue([]);
|
||||
whereMock.mockClear();
|
||||
fromMock.mockClear();
|
||||
selectMock.mockClear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('startTodoistSyncScheduler', () => {
|
||||
it('returns a Timeout handle (so callers can clearTimeout it on shutdown)', async () => {
|
||||
const { startTodoistSyncScheduler } = await import('../scheduler.js');
|
||||
const handle = startTodoistSyncScheduler(60_000);
|
||||
expect(handle).toBeDefined();
|
||||
clearTimeout(handle);
|
||||
});
|
||||
|
||||
it('does not call fetchSignals before the 10s startup delay elapses', async () => {
|
||||
const { startTodoistSyncScheduler } = await import('../scheduler.js');
|
||||
users = [{ userId: 'u1' }];
|
||||
startTodoistSyncScheduler(60_000);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(fetchSignalsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('after the 10s delay, calls fetchSignals once per active-token user', async () => {
|
||||
const { startTodoistSyncScheduler } = await import('../scheduler.js');
|
||||
users = [{ userId: 'alice' }, { userId: 'bob' }, { userId: 'carol' }];
|
||||
startTodoistSyncScheduler(60_000);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_001);
|
||||
|
||||
expect(fetchSignalsMock).toHaveBeenCalledTimes(3);
|
||||
const ids = fetchSignalsMock.mock.calls.map((c) => c[0]).sort();
|
||||
expect(ids).toEqual(['alice', 'bob', 'carol']);
|
||||
});
|
||||
|
||||
it('runs on the given interval after the first tick', async () => {
|
||||
const { startTodoistSyncScheduler } = await import('../scheduler.js');
|
||||
users = [{ userId: 'u1' }];
|
||||
startTodoistSyncScheduler(30_000);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_001); // first run
|
||||
expect(fetchSignalsMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000); // second run
|
||||
expect(fetchSignalsMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000); // third run
|
||||
expect(fetchSignalsMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('no-ops when there are no active-token users', async () => {
|
||||
const { startTodoistSyncScheduler } = await import('../scheduler.js');
|
||||
users = [];
|
||||
startTodoistSyncScheduler(60_000);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_001);
|
||||
expect(fetchSignalsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isolates per-user failures — one rejection does not stop the others', async () => {
|
||||
const { startTodoistSyncScheduler } = await import('../scheduler.js');
|
||||
users = [{ userId: 'good' }, { userId: 'bad' }, { userId: 'also-good' }];
|
||||
fetchSignalsMock.mockImplementation(async (id: string) => {
|
||||
if (id === 'bad') throw new Error('todoist 401');
|
||||
return [];
|
||||
});
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
startTodoistSyncScheduler(60_000);
|
||||
await vi.advanceTimersByTimeAsync(10_001);
|
||||
// allow the awaited Promise.allSettled to settle
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchSignalsMock).toHaveBeenCalledTimes(3);
|
||||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('sync error'), expect.anything());
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('2 ok, 1 failed'));
|
||||
});
|
||||
|
||||
it('survives a db query failure — logs and skips the tick', async () => {
|
||||
const { startTodoistSyncScheduler } = await import('../scheduler.js');
|
||||
whereMock.mockRejectedValueOnce(new Error('sqlite locked'));
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
startTodoistSyncScheduler(60_000);
|
||||
await vi.advanceTimersByTimeAsync(10_001);
|
||||
|
||||
expect(fetchSignalsMock).not.toHaveBeenCalled();
|
||||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('failed to query users'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user