test(backend): verify scheduler webhook lifecycle

This commit is contained in:
Saravana Achu Mac 2026-05-05 11:37:28 -07:00
parent 8f7247c413
commit 3aa385774e
5 changed files with 137 additions and 1 deletions

View File

@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { dispatchToTargetsMock } = vi.hoisted(() => ({
dispatchToTargetsMock: vi.fn(async () => []),
}));
vi.mock('@bytelyst/webhook-dispatch', () => ({
dispatchToTargets: dispatchToTargetsMock,
}));
import { _resetEventBus, getEventBus } from './event-bus.js';
import {
_resetWebhookSubscriberForTests,
initWebhookSubscriber,
isWebhookSubscriberRunning,
registerWebhookTarget,
stopWebhookSubscriber,
} from './webhook-subscriber.js';
describe('webhook subscriber lifecycle', () => {
beforeEach(() => {
_resetWebhookSubscriberForTests();
_resetEventBus();
dispatchToTargetsMock.mockClear();
});
it('does not subscribe until initialized', async () => {
registerWebhookTarget({
id: 'target-1',
url: 'https://example.com/webhook',
secret: 'secret',
events: ['note.created'],
enabled: true,
});
await getEventBus().emit('note.created', {
noteId: 'note-1',
workspaceId: 'ws-1',
userId: 'user-1',
title: 'Created',
});
expect(isWebhookSubscriberRunning()).toBe(false);
expect(dispatchToTargetsMock).not.toHaveBeenCalled();
});
it('initializes idempotently and unsubscribes on stop', async () => {
registerWebhookTarget({
id: 'target-1',
url: 'https://example.com/webhook',
secret: 'secret',
events: ['note.created'],
enabled: true,
});
initWebhookSubscriber();
initWebhookSubscriber();
expect(isWebhookSubscriberRunning()).toBe(true);
await getEventBus().emit('note.created', {
noteId: 'note-1',
workspaceId: 'ws-1',
userId: 'user-1',
title: 'Created',
});
expect(dispatchToTargetsMock).toHaveBeenCalledTimes(1);
stopWebhookSubscriber();
expect(isWebhookSubscriberRunning()).toBe(false);
await getEventBus().emit('note.created', {
noteId: 'note-2',
workspaceId: 'ws-1',
userId: 'user-1',
title: 'Created again',
});
expect(dispatchToTargetsMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -73,6 +73,8 @@ async function dispatchEvent<K extends keyof NoteLettEventMap>(
const unsubscribers: (() => void)[] = [];
export function initWebhookSubscriber(): void {
if (unsubscribers.length > 0) return;
const bus = getEventBus();
unsubscribers.push(
@ -84,7 +86,18 @@ export function initWebhookSubscriber(): void {
);
}
export function isWebhookSubscriberRunning(): boolean {
return unsubscribers.length > 0;
}
export function stopWebhookSubscriber(): void {
for (const unsub of unsubscribers) unsub();
unsubscribers.length = 0;
}
/** @internal — for testing only. */
export function _resetWebhookSubscriberForTests(): void {
stopWebhookSubscriber();
targets.length = 0;
deliveryLog = [];
}

View File

@ -59,7 +59,12 @@ vi.mock('@bytelyst/llm', () => ({
}));
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
import { promptSchedulerRoutes } from './scheduler.js';
import {
isSchedulerLoopRunning,
promptSchedulerRoutes,
startSchedulerLoop,
stopSchedulerLoop,
} from './scheduler.js';
import { notePromptRoutes } from './routes.js';
import { noteRoutes } from '../notes/routes.js';
import { upsertBuiltinTemplate } from './repository.js';
@ -76,6 +81,7 @@ beforeAll(async () => {
});
afterAll(async () => {
stopSchedulerLoop();
await app.close();
});
@ -410,3 +416,24 @@ describe('scheduler diagnostics', () => {
expect(Array.isArray(body.nextRuns)).toBe(true);
});
});
describe('scheduler lifecycle', () => {
beforeEach(() => {
stopSchedulerLoop();
});
it('does not start the scheduler loop when only routes are registered for tests', () => {
expect(isSchedulerLoopRunning()).toBe(false);
});
it('starts idempotently and stops cleanly', () => {
startSchedulerLoop(60_000);
expect(isSchedulerLoopRunning()).toBe(true);
startSchedulerLoop(60_000);
expect(isSchedulerLoopRunning()).toBe(true);
stopSchedulerLoop();
expect(isSchedulerLoopRunning()).toBe(false);
});
});

View File

@ -243,6 +243,10 @@ export function startSchedulerLoop(intervalMs = 60_000, logger?: SchedulerLogger
}, intervalMs);
}
export function isSchedulerLoopRunning(): boolean {
return schedulerInterval !== null;
}
export function stopSchedulerLoop(): void {
if (schedulerInterval) {
clearInterval(schedulerInterval);

View File

@ -8,6 +8,8 @@ const initDatastoreMock = vi.fn(() => undefined);
const diagnosticsRoutesMock = vi.fn(async () => undefined);
const startSchedulerLoopMock = vi.fn();
const stopSchedulerLoopMock = vi.fn();
const initWebhookSubscriberMock = vi.fn();
const stopWebhookSubscriberMock = vi.fn();
const appMock = {
register: vi.fn(async () => undefined),
@ -74,6 +76,10 @@ vi.mock('./lib/request-context.js', () => ({
vi.mock('./lib/feature-flags.js', () => ({ getAllFlags: vi.fn(() => ({})) }));
vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) }));
vi.mock('./lib/diagnostics-routes.js', () => ({ diagnosticsRoutes: diagnosticsRoutesMock }));
vi.mock('./lib/webhook-subscriber.js', () => ({
initWebhookSubscriber: initWebhookSubscriberMock,
stopWebhookSubscriber: stopWebhookSubscriberMock,
}));
vi.mock('./modules/note-shares/repository.js', () => ({ findShareByToken: vi.fn(async () => null) }));
vi.mock('./modules/notes/repository.js', () => ({ getNote: vi.fn(async () => null) }));
@ -99,6 +105,13 @@ describe('server bootstrap', () => {
expect(appMock.register).toHaveBeenCalledTimes(14);
expect(diagnosticsRoutesMock).toHaveBeenCalledWith(appMock);
expect(startSchedulerLoopMock).toHaveBeenCalledWith(60_000, appMock.log);
expect(initWebhookSubscriberMock).toHaveBeenCalledOnce();
expect(appMock.addHook).toHaveBeenCalledWith('onClose', expect.any(Function));
const onCloseHook = appMock.addHook.mock.calls.find(([event]) => event === 'onClose')?.[1];
expect(onCloseHook).toEqual(expect.any(Function));
await onCloseHook();
expect(stopSchedulerLoopMock).toHaveBeenCalledOnce();
expect(stopWebhookSubscriberMock).toHaveBeenCalledOnce();
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
});
});