@shipfox/api-server 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +35 -0
  5. package/LICENSE +21 -0
  6. package/README.md +59 -0
  7. package/dist/config.d.ts +9 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +43 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/e2e.d.ts +10 -0
  12. package/dist/e2e.d.ts.map +1 -0
  13. package/dist/e2e.js +42 -0
  14. package/dist/e2e.js.map +1 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +4 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/instrumentation.d.ts +2 -0
  20. package/dist/instrumentation.d.ts.map +1 -0
  21. package/dist/instrumentation.js +13 -0
  22. package/dist/instrumentation.js.map +1 -0
  23. package/dist/modules.d.ts +3 -0
  24. package/dist/modules.d.ts.map +1 -0
  25. package/dist/modules.js +76 -0
  26. package/dist/modules.js.map +1 -0
  27. package/dist/server.d.ts +16 -0
  28. package/dist/server.d.ts.map +1 -0
  29. package/dist/server.js +215 -0
  30. package/dist/server.js.map +1 -0
  31. package/dist/tsconfig.test.tsbuildinfo +1 -0
  32. package/package.json +80 -0
  33. package/src/config.test.ts +33 -0
  34. package/src/config.ts +54 -0
  35. package/src/e2e.test.ts +101 -0
  36. package/src/e2e.ts +61 -0
  37. package/src/index.ts +3 -0
  38. package/src/instrumentation.ts +8 -0
  39. package/src/modules.test.ts +137 -0
  40. package/src/modules.ts +90 -0
  41. package/src/server.test.ts +539 -0
  42. package/src/server.ts +242 -0
  43. package/tsconfig.build.json +9 -0
  44. package/tsconfig.build.tsbuildinfo +1 -0
  45. package/tsconfig.json +4 -0
  46. package/tsconfig.test.json +8 -0
  47. package/vitest.config.ts +3 -0
@@ -0,0 +1,539 @@
1
+ import type {
2
+ ModuleWorker,
3
+ ModuleWorkersHandle,
4
+ ShipfoxModule,
5
+ StartModuleWorkersOptions,
6
+ } from '@shipfox/node-module';
7
+ import {createServer, runServer, type ServerHandle} from './server.js';
8
+
9
+ const mocks = vi.hoisted(() => {
10
+ const workersHandle = {stop: vi.fn()};
11
+ return {
12
+ captureException: vi.fn(),
13
+ closeApp: vi.fn(),
14
+ closeErrorMonitoring: vi.fn(),
15
+ closePostgresClient: vi.fn(),
16
+ createApp: vi.fn(),
17
+ createE2eAdminAuthMethod: vi.fn(),
18
+ createE2eRouteGroup: vi.fn(),
19
+ createPostgresClient: vi.fn(),
20
+ initializeModules: vi.fn(),
21
+ listen: vi.fn(),
22
+ logger: {error: vi.fn(), info: vi.fn()},
23
+ parseApiTrustProxy: vi.fn(),
24
+ registerModuleMetrics: vi.fn(),
25
+ resetPublishers: vi.fn(),
26
+ resetSubscribers: vi.fn(),
27
+ runModuleStartupTasks: vi.fn(),
28
+ shutdownServiceMetrics: vi.fn(),
29
+ startModuleWorkers: vi.fn(),
30
+ startServiceMetrics: vi.fn(),
31
+ workersHandle,
32
+ apiConfig: {
33
+ API_PORT: undefined as number | undefined,
34
+ API_TRUST_PROXY: 'false',
35
+ E2E_ENABLED: false,
36
+ },
37
+ };
38
+ });
39
+
40
+ vi.mock('@shipfox/node-error-monitoring', () => ({
41
+ captureException: mocks.captureException,
42
+ closeErrorMonitoring: mocks.closeErrorMonitoring,
43
+ }));
44
+ vi.mock('@shipfox/node-fastify', () => ({
45
+ closeApp: mocks.closeApp,
46
+ createApp: mocks.createApp,
47
+ listen: mocks.listen,
48
+ }));
49
+ vi.mock('@shipfox/node-module', () => ({
50
+ initializeModules: mocks.initializeModules,
51
+ registerModuleMetrics: mocks.registerModuleMetrics,
52
+ resetPublishers: mocks.resetPublishers,
53
+ resetSubscribers: mocks.resetSubscribers,
54
+ runModuleStartupTasks: mocks.runModuleStartupTasks,
55
+ startModuleWorkers: mocks.startModuleWorkers,
56
+ }));
57
+ vi.mock('@shipfox/node-opentelemetry', () => ({
58
+ logger: () => mocks.logger,
59
+ shutdownServiceMetrics: mocks.shutdownServiceMetrics,
60
+ startServiceMetrics: mocks.startServiceMetrics,
61
+ }));
62
+ vi.mock('@shipfox/node-postgres', () => ({
63
+ closePostgresClient: mocks.closePostgresClient,
64
+ createPostgresClient: mocks.createPostgresClient,
65
+ }));
66
+ vi.mock('./config.js', () => ({
67
+ config: mocks.apiConfig,
68
+ parseApiTrustProxy: mocks.parseApiTrustProxy,
69
+ }));
70
+ vi.mock('./e2e.js', () => ({
71
+ createE2eAdminAuthMethod: mocks.createE2eAdminAuthMethod,
72
+ createE2eRouteGroup: mocks.createE2eRouteGroup,
73
+ }));
74
+
75
+ function module(): ShipfoxModule {
76
+ return {name: 'test'};
77
+ }
78
+
79
+ function worker(taskQueue = 'runtime-queue'): ModuleWorker {
80
+ return {
81
+ taskQueue,
82
+ workflowsPath: '/tmp/workflows.js',
83
+ activities: () => ({}),
84
+ workflows: [],
85
+ };
86
+ }
87
+
88
+ function lastStartModuleWorkersOptions(): StartModuleWorkersOptions {
89
+ const options = mocks.startModuleWorkers.mock.calls.at(-1)?.[0];
90
+ if (!options) throw new Error('startModuleWorkers was not called');
91
+ return options as StartModuleWorkersOptions;
92
+ }
93
+
94
+ function resetMocks(): void {
95
+ vi.useRealTimers();
96
+ mocks.captureException.mockReset();
97
+ mocks.closeApp.mockReset();
98
+ mocks.closeErrorMonitoring.mockReset();
99
+ mocks.closePostgresClient.mockReset();
100
+ mocks.createApp.mockReset();
101
+ mocks.createE2eAdminAuthMethod.mockReset();
102
+ mocks.createE2eRouteGroup.mockReset();
103
+ mocks.createPostgresClient.mockReset();
104
+ mocks.initializeModules.mockReset();
105
+ mocks.listen.mockReset();
106
+ mocks.logger.error.mockReset();
107
+ mocks.logger.info.mockReset();
108
+ mocks.parseApiTrustProxy.mockReset();
109
+ mocks.registerModuleMetrics.mockReset();
110
+ mocks.resetPublishers.mockReset();
111
+ mocks.resetSubscribers.mockReset();
112
+ mocks.runModuleStartupTasks.mockReset();
113
+ mocks.shutdownServiceMetrics.mockReset();
114
+ mocks.startModuleWorkers.mockReset();
115
+ mocks.startServiceMetrics.mockReset();
116
+ mocks.workersHandle.stop.mockReset();
117
+ mocks.apiConfig.API_PORT = undefined;
118
+ mocks.apiConfig.API_TRUST_PROXY = 'false';
119
+ mocks.apiConfig.E2E_ENABLED = false;
120
+
121
+ mocks.closeApp.mockResolvedValue(undefined);
122
+ mocks.closeErrorMonitoring.mockResolvedValue(true);
123
+ mocks.closePostgresClient.mockResolvedValue(undefined);
124
+ mocks.createApp.mockResolvedValue({});
125
+ mocks.createE2eRouteGroup.mockReturnValue([]);
126
+ mocks.initializeModules.mockResolvedValue({
127
+ auth: [],
128
+ routes: [],
129
+ e2eRoutes: [],
130
+ workers: [worker()],
131
+ });
132
+ mocks.listen.mockResolvedValue('http://127.0.0.1:3000');
133
+ mocks.parseApiTrustProxy.mockReturnValue(false);
134
+ mocks.runModuleStartupTasks.mockResolvedValue(undefined);
135
+ mocks.shutdownServiceMetrics.mockResolvedValue(undefined);
136
+ mocks.startModuleWorkers.mockResolvedValue(mocks.workersHandle);
137
+ mocks.workersHandle.stop.mockResolvedValue(undefined);
138
+ }
139
+
140
+ const servers: ServerHandle[] = [];
141
+
142
+ async function createTestServer(
143
+ options: Parameters<typeof createServer>[0],
144
+ ): Promise<ServerHandle> {
145
+ const server = await createServer(options);
146
+ servers.push(server);
147
+ return server;
148
+ }
149
+
150
+ async function stopTestServers(): Promise<void> {
151
+ await Promise.all(servers.splice(0).map((server) => server.stop().catch(() => undefined)));
152
+ }
153
+
154
+ describe('createServer', () => {
155
+ beforeEach(resetMocks);
156
+
157
+ afterEach(async () => {
158
+ await stopTestServers();
159
+ vi.useRealTimers();
160
+ vi.restoreAllMocks();
161
+ });
162
+
163
+ it('initializes modules, metrics, startup tasks, and the HTTP app', async () => {
164
+ const modules = [module()];
165
+
166
+ await createTestServer({modules});
167
+
168
+ expect(mocks.startServiceMetrics).toHaveBeenCalledWith({serviceName: 'api'});
169
+ expect(mocks.createPostgresClient).toHaveBeenCalledOnce();
170
+ expect(mocks.initializeModules).toHaveBeenCalledWith({modules});
171
+ expect(mocks.registerModuleMetrics).toHaveBeenCalledWith({modules});
172
+ expect(mocks.runModuleStartupTasks).toHaveBeenCalledWith({modules});
173
+ expect(mocks.createApp).toHaveBeenCalledWith({
174
+ auth: [],
175
+ routes: [],
176
+ fastifyOptions: {trustProxy: false},
177
+ });
178
+ });
179
+
180
+ it('starts module workers before listening for HTTP requests', async () => {
181
+ const server = await createTestServer({modules: [module()]});
182
+ let resolveWorkers: ((handle: ModuleWorkersHandle) => void) | undefined;
183
+ const workersStarted = new Promise<void>((resolve) => {
184
+ mocks.startModuleWorkers.mockImplementationOnce(
185
+ () =>
186
+ new Promise<ModuleWorkersHandle>((innerResolve) => {
187
+ resolveWorkers = innerResolve;
188
+ resolve();
189
+ }),
190
+ );
191
+ });
192
+
193
+ const start = server.start();
194
+ await workersStarted;
195
+
196
+ expect(mocks.listen).not.toHaveBeenCalled();
197
+
198
+ resolveWorkers?.(mocks.workersHandle);
199
+ await start;
200
+
201
+ expect(mocks.startModuleWorkers.mock.invocationCallOrder[0]).toBeLessThan(
202
+ mocks.listen.mock.invocationCallOrder[0] ?? 0,
203
+ );
204
+ });
205
+
206
+ it('passes API_PORT to the HTTP listener when configured', async () => {
207
+ mocks.apiConfig.API_PORT = 55_291;
208
+ const server = await createTestServer({modules: [module()]});
209
+
210
+ await server.start();
211
+
212
+ expect(mocks.listen).toHaveBeenCalledWith({port: 55_291});
213
+ });
214
+
215
+ it('does not listen when module worker startup fails', async () => {
216
+ const failure = new Error('worker boot failed');
217
+ mocks.startModuleWorkers.mockRejectedValueOnce(failure);
218
+ const server = await createTestServer({modules: [module()]});
219
+
220
+ const result = server.start();
221
+
222
+ await expect(result).rejects.toBe(failure);
223
+ expect(mocks.listen).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it('releases service metrics and Postgres when boot fails', async () => {
227
+ const failure = new Error('migration failed');
228
+ mocks.initializeModules.mockRejectedValueOnce(failure);
229
+
230
+ const result = createTestServer({modules: [module()]});
231
+
232
+ await expect(result).rejects.toBe(failure);
233
+ expect(mocks.shutdownServiceMetrics.mock.invocationCallOrder[0]).toBeLessThan(
234
+ mocks.closePostgresClient.mock.invocationCallOrder[0] ?? 0,
235
+ );
236
+ expect(mocks.resetPublishers).toHaveBeenCalledOnce();
237
+ expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
238
+ });
239
+
240
+ it('rejects a second server while another server owns process resources', async () => {
241
+ const firstServer = await createTestServer({modules: [module()]});
242
+
243
+ const secondServer = createServer({modules: [module()]});
244
+
245
+ await expect(secondServer).rejects.toThrow(
246
+ 'Cannot create a second API server before the existing server stops',
247
+ );
248
+ expect(mocks.startServiceMetrics).toHaveBeenCalledOnce();
249
+ expect(mocks.createPostgresClient).toHaveBeenCalledOnce();
250
+ await firstServer.stop();
251
+ });
252
+
253
+ it('preserves a boot failure when cleanup steps fail', async () => {
254
+ const failure = new Error('migration failed');
255
+ const cleanupFailure = new Error('metrics shutdown failed');
256
+ mocks.initializeModules.mockRejectedValueOnce(failure);
257
+ mocks.shutdownServiceMetrics.mockRejectedValueOnce(cleanupFailure);
258
+
259
+ const result = createTestServer({modules: [module()]});
260
+
261
+ await expect(result).rejects.toBe(failure);
262
+ expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
263
+ expect(mocks.resetPublishers).toHaveBeenCalledOnce();
264
+ expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
265
+ expect(mocks.logger.error).toHaveBeenCalledWith(
266
+ {err: cleanupFailure, bootError: failure},
267
+ 'Failed to clean up API server boot',
268
+ );
269
+ });
270
+
271
+ it('stops once when stop is called twice', async () => {
272
+ const server = await createTestServer({modules: [module()]});
273
+ await server.start();
274
+
275
+ const firstStop = server.stop();
276
+ const secondStop = server.stop();
277
+ await firstStop;
278
+ await secondStop;
279
+
280
+ expect(mocks.closeApp).toHaveBeenCalledOnce();
281
+ expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
282
+ expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
283
+ expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
284
+ expect(mocks.resetPublishers).toHaveBeenCalledOnce();
285
+ expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
286
+ expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
287
+ });
288
+
289
+ it('stops before workers start', async () => {
290
+ const server = await createTestServer({modules: [module()]});
291
+
292
+ await server.stop();
293
+
294
+ expect(mocks.closeApp).toHaveBeenCalledOnce();
295
+ expect(mocks.workersHandle.stop).not.toHaveBeenCalled();
296
+ expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
297
+ expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
298
+ expect(mocks.resetPublishers).toHaveBeenCalledOnce();
299
+ expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
300
+ });
301
+
302
+ it('waits for worker startup before stopping', async () => {
303
+ const server = await createTestServer({modules: [module()]});
304
+ let resolveWorkers: ((handle: ModuleWorkersHandle) => void) | undefined;
305
+ const workersStarted = new Promise<void>((resolve) => {
306
+ mocks.startModuleWorkers.mockImplementationOnce(
307
+ () =>
308
+ new Promise<ModuleWorkersHandle>((innerResolve) => {
309
+ resolveWorkers = innerResolve;
310
+ resolve();
311
+ }),
312
+ );
313
+ });
314
+
315
+ const start = server.start();
316
+ await workersStarted;
317
+ const stop = server.stop();
318
+
319
+ expect(mocks.closeApp).not.toHaveBeenCalled();
320
+
321
+ resolveWorkers?.(mocks.workersHandle);
322
+ await expect(start).rejects.toThrow('API server stopped during startup');
323
+ await stop;
324
+
325
+ expect(mocks.listen).not.toHaveBeenCalled();
326
+ expect(mocks.closeApp).toHaveBeenCalledOnce();
327
+ expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
328
+ expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
329
+ expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
330
+ expect(mocks.resetPublishers).toHaveBeenCalledOnce();
331
+ expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
332
+ expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
333
+ });
334
+
335
+ it('stops resources in lifecycle order', async () => {
336
+ const server = await createTestServer({modules: [module()]});
337
+ await server.start();
338
+
339
+ await server.stop();
340
+
341
+ expect(mocks.closeApp.mock.invocationCallOrder[0]).toBeLessThan(
342
+ mocks.workersHandle.stop.mock.invocationCallOrder[0] ?? 0,
343
+ );
344
+ expect(mocks.workersHandle.stop.mock.invocationCallOrder[0]).toBeLessThan(
345
+ mocks.shutdownServiceMetrics.mock.invocationCallOrder[0] ?? 0,
346
+ );
347
+ expect(mocks.shutdownServiceMetrics.mock.invocationCallOrder[0]).toBeLessThan(
348
+ mocks.closePostgresClient.mock.invocationCallOrder[0] ?? 0,
349
+ );
350
+ expect(mocks.closePostgresClient.mock.invocationCallOrder[0]).toBeLessThan(
351
+ mocks.resetPublishers.mock.invocationCallOrder[0] ?? 0,
352
+ );
353
+ expect(mocks.resetSubscribers.mock.invocationCallOrder[0]).toBeLessThan(
354
+ mocks.closeErrorMonitoring.mock.invocationCallOrder[0] ?? 0,
355
+ );
356
+ });
357
+
358
+ it('continues teardown after an earlier cleanup failure', async () => {
359
+ const server = await createTestServer({modules: [module()]});
360
+ await server.start();
361
+ mocks.closeApp.mockRejectedValueOnce(new Error('close app failed'));
362
+ mocks.workersHandle.stop.mockRejectedValueOnce(new Error('stop workers failed'));
363
+ mocks.shutdownServiceMetrics.mockRejectedValueOnce(new Error('shutdown metrics failed'));
364
+ mocks.closePostgresClient.mockRejectedValueOnce(new Error('close Postgres failed'));
365
+ mocks.resetPublishers.mockImplementationOnce(() => {
366
+ throw new Error('reset publishers failed');
367
+ });
368
+ mocks.resetSubscribers.mockImplementationOnce(() => {
369
+ throw new Error('reset subscribers failed');
370
+ });
371
+ mocks.closeErrorMonitoring.mockRejectedValueOnce(new Error('close monitoring failed'));
372
+
373
+ const result = server.stop();
374
+
375
+ await expect(result).rejects.toBeInstanceOf(AggregateError);
376
+ expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
377
+ expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
378
+ expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
379
+ expect(mocks.resetPublishers).toHaveBeenCalledOnce();
380
+ expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
381
+ expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
382
+ });
383
+
384
+ it('keeps process resources reserved until a failed stop succeeds on retry', async () => {
385
+ const server = await createTestServer({modules: [module()]});
386
+ mocks.closeApp.mockRejectedValueOnce(new Error('close app failed'));
387
+
388
+ await expect(server.stop()).rejects.toThrow('close app failed');
389
+ await expect(createServer({modules: [module()]})).rejects.toThrow(
390
+ 'Cannot create a second API server before the existing server stops',
391
+ );
392
+
393
+ await server.stop();
394
+ const nextServer = await createTestServer({modules: [module()]});
395
+ await nextServer.stop();
396
+ });
397
+
398
+ it('reports worker failures after closing HTTP and error monitoring', async () => {
399
+ const onWorkerFailure = vi.fn();
400
+ const server = await createTestServer({modules: [module()], onWorkerFailure});
401
+ await server.start();
402
+ const failure = new Error('poller failed');
403
+ const failedWorker = worker();
404
+
405
+ await lastStartModuleWorkersOptions().onWorkerFailure?.(failure, failedWorker);
406
+
407
+ expect(mocks.logger.error).toHaveBeenCalledWith(
408
+ {err: failure, taskQueue: 'runtime-queue'},
409
+ 'Module worker stopped unexpectedly',
410
+ );
411
+ expect(mocks.captureException).toHaveBeenCalledWith(failure);
412
+ expect(mocks.closeApp).toHaveBeenCalledOnce();
413
+ expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
414
+ expect(mocks.closeErrorMonitoring.mock.invocationCallOrder[0]).toBeLessThan(
415
+ onWorkerFailure.mock.invocationCallOrder[0] ?? 0,
416
+ );
417
+ });
418
+
419
+ it('reports a timed-out HTTP close before invoking the worker failure hook', async () => {
420
+ vi.useFakeTimers();
421
+ const onWorkerFailure = vi.fn();
422
+ mocks.closeApp.mockReturnValueOnce(new Promise(() => undefined));
423
+ const server = await createTestServer({modules: [module()], onWorkerFailure});
424
+ await server.start();
425
+
426
+ const failure = new Error('poller failed');
427
+ const result = Promise.resolve(
428
+ lastStartModuleWorkersOptions().onWorkerFailure?.(failure, worker()),
429
+ );
430
+ await vi.advanceTimersByTimeAsync(10_000);
431
+ await result;
432
+
433
+ expect(mocks.logger.error).toHaveBeenCalledWith(
434
+ {timeoutMs: 10_000},
435
+ 'Timed out closing HTTP server after worker failure',
436
+ );
437
+ expect(onWorkerFailure).toHaveBeenCalledWith(
438
+ failure,
439
+ expect.objectContaining({taskQueue: 'runtime-queue'}),
440
+ );
441
+ });
442
+
443
+ it('flushes error monitoring when HTTP close rejects', async () => {
444
+ const onWorkerFailure = vi.fn();
445
+ const closeFailure = new Error('onClose failed');
446
+ mocks.closeApp.mockRejectedValueOnce(closeFailure);
447
+ const server = await createTestServer({modules: [module()], onWorkerFailure});
448
+ await server.start();
449
+
450
+ await lastStartModuleWorkersOptions().onWorkerFailure?.(new Error('poller failed'), worker());
451
+
452
+ expect(mocks.logger.error).toHaveBeenCalledWith(
453
+ {err: closeFailure},
454
+ 'Failed to close HTTP server after worker failure',
455
+ );
456
+ expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
457
+ expect(mocks.closeErrorMonitoring.mock.invocationCallOrder[0]).toBeLessThan(
458
+ onWorkerFailure.mock.invocationCallOrder[0] ?? 0,
459
+ );
460
+ });
461
+ });
462
+
463
+ describe('runServer', () => {
464
+ beforeEach(() => {
465
+ vi.restoreAllMocks();
466
+ resetMocks();
467
+ });
468
+
469
+ afterEach(() => {
470
+ vi.restoreAllMocks();
471
+ });
472
+
473
+ it('reports a server-construction error', async () => {
474
+ const failure = new Error('module initialization failed');
475
+ const onStartupFailure = vi.fn();
476
+ mocks.initializeModules.mockRejectedValueOnce(failure);
477
+
478
+ const result = runServer({modules: [module()], onStartupFailure});
479
+
480
+ await expect(result).rejects.toBe(failure);
481
+ expect(onStartupFailure).toHaveBeenCalledWith(failure);
482
+ expect(mocks.closeErrorMonitoring).not.toHaveBeenCalled();
483
+ });
484
+
485
+ it('reports its startup error before releasing the server', async () => {
486
+ const failure = new Error('listener unavailable');
487
+ const onStartupFailure = vi.fn();
488
+ mocks.listen.mockRejectedValueOnce(failure);
489
+
490
+ const result = runServer({modules: [module()], onStartupFailure});
491
+
492
+ await expect(result).rejects.toBe(failure);
493
+ expect(onStartupFailure).toHaveBeenCalledWith(failure);
494
+ expect(mocks.closeApp).toHaveBeenCalledOnce();
495
+ expect(mocks.workersHandle.stop).toHaveBeenCalledOnce();
496
+ expect(mocks.shutdownServiceMetrics).toHaveBeenCalledOnce();
497
+ expect(mocks.closePostgresClient).toHaveBeenCalledOnce();
498
+ expect(mocks.resetPublishers).toHaveBeenCalledOnce();
499
+ expect(mocks.resetSubscribers).toHaveBeenCalledOnce();
500
+ expect(mocks.closeErrorMonitoring).toHaveBeenCalledWith(2_000);
501
+ expect(onStartupFailure.mock.invocationCallOrder[0]).toBeLessThan(
502
+ mocks.closeErrorMonitoring.mock.invocationCallOrder[0] ?? 0,
503
+ );
504
+ });
505
+
506
+ it('installs SIGTERM and SIGINT handlers that stop before exiting successfully', async () => {
507
+ const handlers = new Map<string, () => void>();
508
+ const onceSpy = vi.spyOn(process, 'once').mockImplementation(((
509
+ signal: NodeJS.Signals,
510
+ listener: () => void,
511
+ ) => {
512
+ handlers.set(signal, listener as () => void);
513
+ return process;
514
+ }) as never);
515
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
516
+ const handle = await runServer({modules: [module()]});
517
+
518
+ expect(onceSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
519
+ expect(onceSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
520
+
521
+ handlers.get('SIGTERM')?.();
522
+ await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(0));
523
+
524
+ expect(mocks.closeApp.mock.invocationCallOrder[0]).toBeLessThan(
525
+ exitSpy.mock.invocationCallOrder[0] ?? 0,
526
+ );
527
+ await handle.stop();
528
+ });
529
+
530
+ it('exits unsuccessfully when a module worker fails', async () => {
531
+ vi.spyOn(process, 'once').mockImplementation((() => process) as never);
532
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
533
+ await runServer({modules: [module()]});
534
+
535
+ await lastStartModuleWorkersOptions().onWorkerFailure?.(new Error('poller failed'), worker());
536
+
537
+ expect(exitSpy).toHaveBeenCalledWith(1);
538
+ });
539
+ });