diff --git a/backend/src/lib/webhook-subscriber.test.ts b/backend/src/lib/webhook-subscriber.test.ts new file mode 100644 index 0000000..aa5454a --- /dev/null +++ b/backend/src/lib/webhook-subscriber.test.ts @@ -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); + }); +}); diff --git a/backend/src/lib/webhook-subscriber.ts b/backend/src/lib/webhook-subscriber.ts index 4978f92..37e138d 100644 --- a/backend/src/lib/webhook-subscriber.ts +++ b/backend/src/lib/webhook-subscriber.ts @@ -73,6 +73,8 @@ async function dispatchEvent( 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 = []; +} diff --git a/backend/src/modules/note-prompts/scheduler.test.ts b/backend/src/modules/note-prompts/scheduler.test.ts index 20d8e35..d0b3dda 100644 --- a/backend/src/modules/note-prompts/scheduler.test.ts +++ b/backend/src/modules/note-prompts/scheduler.test.ts @@ -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); + }); +}); diff --git a/backend/src/modules/note-prompts/scheduler.ts b/backend/src/modules/note-prompts/scheduler.ts index 70e39b9..38378f3 100644 --- a/backend/src/modules/note-prompts/scheduler.ts +++ b/backend/src/modules/note-prompts/scheduler.ts @@ -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); diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index 0dfa822..e1cdde4 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -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' }); }); });