test(backend): verify scheduler webhook lifecycle
This commit is contained in:
parent
8f7247c413
commit
3aa385774e
79
backend/src/lib/webhook-subscriber.test.ts
Normal file
79
backend/src/lib/webhook-subscriber.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -73,6 +73,8 @@ async function dispatchEvent<K extends keyof NoteLettEventMap>(
|
|||||||
const unsubscribers: (() => void)[] = [];
|
const unsubscribers: (() => void)[] = [];
|
||||||
|
|
||||||
export function initWebhookSubscriber(): void {
|
export function initWebhookSubscriber(): void {
|
||||||
|
if (unsubscribers.length > 0) return;
|
||||||
|
|
||||||
const bus = getEventBus();
|
const bus = getEventBus();
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
@ -84,7 +86,18 @@ export function initWebhookSubscriber(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWebhookSubscriberRunning(): boolean {
|
||||||
|
return unsubscribers.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function stopWebhookSubscriber(): void {
|
export function stopWebhookSubscriber(): void {
|
||||||
for (const unsub of unsubscribers) unsub();
|
for (const unsub of unsubscribers) unsub();
|
||||||
unsubscribers.length = 0;
|
unsubscribers.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal — for testing only. */
|
||||||
|
export function _resetWebhookSubscriberForTests(): void {
|
||||||
|
stopWebhookSubscriber();
|
||||||
|
targets.length = 0;
|
||||||
|
deliveryLog = [];
|
||||||
|
}
|
||||||
|
|||||||
@ -59,7 +59,12 @@ vi.mock('@bytelyst/llm', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
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 { notePromptRoutes } from './routes.js';
|
||||||
import { noteRoutes } from '../notes/routes.js';
|
import { noteRoutes } from '../notes/routes.js';
|
||||||
import { upsertBuiltinTemplate } from './repository.js';
|
import { upsertBuiltinTemplate } from './repository.js';
|
||||||
@ -76,6 +81,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
stopSchedulerLoop();
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -410,3 +416,24 @@ describe('scheduler diagnostics', () => {
|
|||||||
expect(Array.isArray(body.nextRuns)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -243,6 +243,10 @@ export function startSchedulerLoop(intervalMs = 60_000, logger?: SchedulerLogger
|
|||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSchedulerLoopRunning(): boolean {
|
||||||
|
return schedulerInterval !== null;
|
||||||
|
}
|
||||||
|
|
||||||
export function stopSchedulerLoop(): void {
|
export function stopSchedulerLoop(): void {
|
||||||
if (schedulerInterval) {
|
if (schedulerInterval) {
|
||||||
clearInterval(schedulerInterval);
|
clearInterval(schedulerInterval);
|
||||||
|
|||||||
@ -8,6 +8,8 @@ const initDatastoreMock = vi.fn(() => undefined);
|
|||||||
const diagnosticsRoutesMock = vi.fn(async () => undefined);
|
const diagnosticsRoutesMock = vi.fn(async () => undefined);
|
||||||
const startSchedulerLoopMock = vi.fn();
|
const startSchedulerLoopMock = vi.fn();
|
||||||
const stopSchedulerLoopMock = vi.fn();
|
const stopSchedulerLoopMock = vi.fn();
|
||||||
|
const initWebhookSubscriberMock = vi.fn();
|
||||||
|
const stopWebhookSubscriberMock = vi.fn();
|
||||||
|
|
||||||
const appMock = {
|
const appMock = {
|
||||||
register: vi.fn(async () => undefined),
|
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/feature-flags.js', () => ({ getAllFlags: vi.fn(() => ({})) }));
|
||||||
vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) }));
|
vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) }));
|
||||||
vi.mock('./lib/diagnostics-routes.js', () => ({ diagnosticsRoutes: diagnosticsRoutesMock }));
|
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/note-shares/repository.js', () => ({ findShareByToken: vi.fn(async () => null) }));
|
||||||
vi.mock('./modules/notes/repository.js', () => ({ getNote: 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(appMock.register).toHaveBeenCalledTimes(14);
|
||||||
expect(diagnosticsRoutesMock).toHaveBeenCalledWith(appMock);
|
expect(diagnosticsRoutesMock).toHaveBeenCalledWith(appMock);
|
||||||
expect(startSchedulerLoopMock).toHaveBeenCalledWith(60_000, appMock.log);
|
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' });
|
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user