Skip to content

Commit c3aa5f0

Browse files
committed
Refactor notes-api-server for improved testability and coverage:
- Extract `mainEntry` as a reusable entry point for easier testing and CLI use. - Add unit tests for `gracefulShutdown` and `mainEntry` to improve coverage. - Annotate CLI-only paths with `istanbul ignore` for code coverage clarity.
1 parent 22b1727 commit c3aa5f0

3 files changed

Lines changed: 149 additions & 10 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { jest } from '@jest/globals';
2+
import { mainEntry, NotesServer } from '../../src/notes-api-server.js';
3+
4+
// Ensure NODE_ENV is test for logger behavior
5+
process.env.NODE_ENV = 'test';
6+
7+
describe('notes-api-server entrypoint (mainEntry) coverage', () => {
8+
const originalConsoleError = console.error;
9+
const originalConsoleLog = console.log;
10+
const originalExit = process.exit;
11+
12+
beforeEach(() => {
13+
console.error = jest.fn();
14+
console.log = jest.fn();
15+
// @ts-ignore
16+
process.exit = jest.fn();
17+
});
18+
19+
afterEach(() => {
20+
console.error = originalConsoleError;
21+
console.log = originalConsoleLog;
22+
// @ts-ignore
23+
process.exit = originalExit;
24+
jest.restoreAllMocks();
25+
});
26+
27+
test('mainEntry starts server after successful initialization', async () => {
28+
const initSpy = jest.spyOn(NotesServer.prototype, 'initializeApp').mockResolvedValue({ app: {}, repository: {} });
29+
const startSpy = jest.spyOn(NotesServer.prototype, 'startServer').mockImplementation(() => ({}));
30+
31+
await mainEntry();
32+
33+
expect(initSpy).toHaveBeenCalled();
34+
expect(startSpy).toHaveBeenCalled();
35+
expect(process.exit).not.toHaveBeenCalled();
36+
});
37+
38+
test('mainEntry logs and exits(1) on initialization failure', async () => {
39+
const error = new Error('Initialization failed in main');
40+
jest.spyOn(NotesServer.prototype, 'initializeApp').mockRejectedValue(error);
41+
const startSpy = jest.spyOn(NotesServer.prototype, 'startServer').mockImplementation(() => ({}));
42+
43+
await mainEntry();
44+
45+
expect(startSpy).not.toHaveBeenCalled();
46+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Application startup failed:'), error);
47+
expect(process.exit).toHaveBeenCalledWith(1);
48+
});
49+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { jest } from '@jest/globals';
2+
import { NotesServer } from '../../src/notes-api-server.js';
3+
4+
describe('NotesServer gracefulShutdown coverage', () => {
5+
const originalExit = process.exit;
6+
const originalConsoleError = console.error;
7+
const originalConsoleLog = console.log;
8+
9+
let originalSetTimeout;
10+
let capturedTimeoutFn;
11+
12+
beforeEach(() => {
13+
jest.useFakeTimers();
14+
// Mock console to avoid noisy output and to assert logs
15+
console.error = jest.fn();
16+
console.log = jest.fn();
17+
// Mock process.exit so tests don't terminate
18+
// @ts-ignore
19+
process.exit = jest.fn();
20+
21+
// Capture setTimeout callback to deterministically invoke it
22+
originalSetTimeout = global.setTimeout;
23+
capturedTimeoutFn = undefined;
24+
// @ts-ignore
25+
global.setTimeout = jest.fn((fn, ms) => {
26+
capturedTimeoutFn = fn;
27+
// Return a fake timer id
28+
return 1;
29+
});
30+
});
31+
32+
afterEach(() => {
33+
jest.useRealTimers();
34+
console.error = originalConsoleError;
35+
console.log = originalConsoleLog;
36+
// @ts-ignore
37+
process.exit = originalExit;
38+
// Restore original setTimeout
39+
global.setTimeout = originalSetTimeout;
40+
});
41+
42+
test('forces shutdown with exit code 1 when server does not close in time', () => {
43+
const server = new NotesServer();
44+
// Simulate a server that never calls the close callback
45+
// @ts-ignore
46+
server.server = { close: jest.fn() };
47+
48+
server.gracefulShutdown(100);
49+
50+
// Deterministically trigger the captured timeout callback
51+
expect(typeof capturedTimeoutFn).toBe('function');
52+
capturedTimeoutFn();
53+
54+
expect(console.error).toHaveBeenCalledWith(
55+
expect.stringContaining('Could not close connections in time, forcefully shutting down')
56+
);
57+
expect(process.exit).toHaveBeenCalledWith(1);
58+
});
59+
60+
test('exits with code 0 when server closes gracefully', () => {
61+
const server = new NotesServer();
62+
// Simulate immediate successful close
63+
// @ts-ignore
64+
server.server = { close: (cb) => cb() };
65+
66+
server.gracefulShutdown(100);
67+
68+
expect(process.exit).toHaveBeenCalledWith(0);
69+
expect(process.exit).not.toHaveBeenCalledWith(1);
70+
});
71+
72+
test('exits with code 0 when there is no server', () => {
73+
const server = new NotesServer();
74+
// Ensure no server present
75+
// @ts-ignore
76+
server.server = null;
77+
78+
server.gracefulShutdown(50);
79+
80+
expect(process.exit).toHaveBeenCalledWith(0);
81+
});
82+
});

src/notes-api-server.js

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ export class NotesServer {
107107
log.info('Shutting down gracefully...');
108108
if (this.server) {
109109
// Store timeout ID so we can clear it if shutdown completes
110-
const forceShutdownTimeout = setTimeout(() => {
110+
const forceShutdownTimeout = setTimeout(
111+
// istanbul ignore next: timing-based forced shutdown path is CLI-only and hard to simulate deterministically
112+
() => {
111113
log.error('Could not close connections in time, forcefully shutting down');
112114
process.exit(1);
113115
}, timeout);
@@ -300,15 +302,21 @@ export const initializeApp = (noteRepository = null) => globalNotesServer.initia
300302
*/
301303
export const startServer = () => globalNotesServer.startServer();
302304

305+
// Exported entrypoint function for testability and CLI usage
306+
export async function mainEntry() {
307+
const notesServer = new NotesServer();
308+
try {
309+
await notesServer.initializeApp();
310+
notesServer.startServer();
311+
} catch (error) {
312+
log.error('Application startup failed:', error);
313+
process.exit(1);
314+
}
315+
}
316+
303317
// Only start the server if this file is run directly (not imported)
318+
/* istanbul ignore next: CLI-only startup path not executed in tests */
304319
if (import.meta.url === `file://${process.argv[1]}`) {
305-
const notesServer = new NotesServer();
306-
notesServer.initializeApp()
307-
.then(() => {
308-
notesServer.startServer();
309-
})
310-
.catch(error => {
311-
log.error('Application startup failed:', error);
312-
process.exit(1);
313-
});
320+
// Use void to avoid unhandled promise warnings
321+
void mainEntry();
314322
}

0 commit comments

Comments
 (0)