Skip to content

Commit 289e190

Browse files
committed
test: add more unit tests
1 parent af2ecd3 commit 289e190

9 files changed

Lines changed: 496 additions & 0 deletions

tests/config.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,66 @@ describe('config', () => {
134134

135135
expect(actual).toEqual(expected);
136136
});
137+
138+
it('should support getConfig/setConfig aliases', () => {
139+
const { getConfig, setConfig } = require('../src/config');
140+
141+
setConfig({ logger: { level: 'error' } });
142+
const cfg = getConfig();
143+
144+
expect(cfg.logger.level).toBe('error');
145+
});
146+
147+
it('should update and read server config using dedicated helpers', () => {
148+
const { setCatbeeServerGlobalConfig, getCatbeeServerGlobalConfig } = require('../src/config');
149+
150+
setCatbeeServerGlobalConfig({ host: '127.0.0.1', metrics: { enable: true } });
151+
const serverCfg = getCatbeeServerGlobalConfig();
152+
153+
expect(serverCfg.host).toBe('127.0.0.1');
154+
expect(serverCfg.metrics.enable).toBe(true);
155+
});
156+
157+
it('should evaluate requestLogging.ignorePaths predicate for skipped and non-skipped routes', () => {
158+
const { getCatbeeGlobalConfig } = require('../src/config');
159+
const cfg = getCatbeeGlobalConfig();
160+
161+
expect(cfg.server.requestLogging.ignorePaths({ path: '/healthz/ready' }, {})).toBe(true);
162+
expect(cfg.server.requestLogging.ignorePaths({ path: '/api/users' }, {})).toBe(false);
163+
});
164+
165+
it('should build enabled server option objects when env flags are true', () => {
166+
jest.resetModules();
167+
jest.doMock('../src/env', () =>
168+
getEnvMockModule({
169+
getBoolean: jest.fn((key: string, fallback: boolean) => {
170+
const enabledKeys = new Set([
171+
'SERVER_CORS_ENABLE',
172+
'SERVER_HELMET_ENABLE',
173+
'SERVER_COMPRESSION_ENABLE',
174+
'SERVER_COOKIE_PARSER_ENABLE'
175+
]);
176+
return enabledKeys.has(key) ? true : fallback;
177+
}),
178+
get: jest.fn((key: string, fallback: string) => {
179+
if (key === 'SERVER_HEALTH_CHECK_PATH') return '/hc';
180+
return fallback;
181+
}),
182+
isDev: jest.fn(() => false),
183+
isTest: jest.fn(() => false)
184+
})
185+
);
186+
187+
const { getCatbeeGlobalConfig } = require('../src/config');
188+
const cfg = getCatbeeGlobalConfig();
189+
190+
expect(cfg.server.cors).toEqual({});
191+
expect(cfg.server.helmet).toEqual({});
192+
expect(cfg.server.compression).toEqual({});
193+
expect(cfg.server.cookieParser).toEqual({});
194+
expect(cfg.server.healthCheck.path).toBe('/hc');
195+
196+
jest.resetModules();
197+
jest.doMock('../src/env', () => getEnvMockModule());
198+
});
137199
});

tests/server/server.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,27 @@ describe('ExpressServer', () => {
162162
await killServer(server);
163163
});
164164

165+
it('should return configured port when server address is not an object', async () => {
166+
const server = new ExpressServer({ ...baseConfig, port: 4012 });
167+
await server.waitUntilReady();
168+
169+
(server as any).server = {
170+
address: () => 'named-pipe',
171+
listening: false
172+
};
173+
174+
expect(server.getPort()).toBe(4012);
175+
expect(server.isRunning()).toBe(false);
176+
});
177+
178+
it('should treat null https as enabled in isHttps guard', async () => {
179+
const server = new ExpressServer({ ...(baseConfig as any), https: null as any });
180+
await server.waitUntilReady();
181+
182+
expect(server.isHttps()).toBe(true);
183+
expect(server.getProtocol()).toBe('http');
184+
});
185+
165186
it('should handle HTTPS configuration', async () => {
166187
const httpsConfig = {
167188
...baseConfig,
@@ -201,6 +222,13 @@ describe('ExpressServer', () => {
201222
expect(() => server.setPort(70000)).toThrow('Port must be a valid number between 0 and 65535');
202223
});
203224

225+
it('should throw when setting invalid host', async () => {
226+
const server = new ExpressServer(baseConfig);
227+
await server.waitUntilReady();
228+
229+
expect(() => server.setHost('not a valid host')).toThrow('Host must be a valid hostname or IP address');
230+
});
231+
204232
it('should allow setting host before server starts', async () => {
205233
const server = new ExpressServer(baseConfig);
206234
await server.waitUntilReady();

tests/utils/context-store.utils.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ describe('ContextStoreUtils', () => {
106106
});
107107
});
108108

109+
it('ContextStore.patch throws outside active context', () => {
110+
expect(() => ContextStore.patch({ [StoreKeys.USER_ID]: 'no-context' })).toThrow(
111+
/Failed to patch: AsyncLocalStorage store is not initialized/
112+
);
113+
});
114+
109115
it('ContextStore.withValue temporarily overrides value in context', () => {
110116
ContextStore.run({ [StoreKeys.REQUEST_ID]: 'orig' }, () => {
111117
const result = ContextStore.withValue(StoreKeys.REQUEST_ID, 'temp', () => {
@@ -116,6 +122,22 @@ describe('ContextStoreUtils', () => {
116122
});
117123
});
118124

125+
it('ContextStore.withValue restores by deleting when no original value existed', () => {
126+
ContextStore.run({}, () => {
127+
const valueInside = ContextStore.withValue(StoreKeys.USER_ID, 'temp-user', () =>
128+
ContextStore.get(StoreKeys.USER_ID)
129+
);
130+
expect(valueInside).toBe('temp-user');
131+
expect(ContextStore.get(StoreKeys.USER_ID)).toBeUndefined();
132+
});
133+
});
134+
135+
it('ContextStore.withValue throws outside active context', () => {
136+
expect(() => ContextStore.withValue(StoreKeys.USER_ID, 'temp', () => 'ok')).toThrow(
137+
/Failed to set temporary value: AsyncLocalStorage store is not initialized/
138+
);
139+
});
140+
119141
it('ContextStore.extend creates a new context inheriting from current', () => {
120142
ContextStore.run({ [StoreKeys.REQUEST_ID]: 'base' }, () => {
121143
const result = ContextStore.extend({ [StoreKeys.USER_ID]: 'extended' }, () => ({
@@ -129,6 +151,13 @@ describe('ContextStoreUtils', () => {
129151
});
130152
});
131153

154+
it('ContextStore.extend works without an existing context', () => {
155+
const result = ContextStore.extend({ [StoreKeys.REQUEST_ID]: 'new-root' }, () =>
156+
ContextStore.get(StoreKeys.REQUEST_ID)
157+
);
158+
expect(result).toBe('new-root');
159+
});
160+
132161
it('TypedContextKey works for get/set/exists/delete', () => {
133162
const key = new TypedContextKey<string>(StoreKeys.CORRELATION_ID, 'def');
134163
ContextStore.run({}, () => {

tests/utils/date.builder.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,13 @@ describe('DateBuilder', () => {
281281
expect(result.getSecond()).toBe(59);
282282
});
283283

284+
it('should get end of week', () => {
285+
const result = testDate.endOfWeek();
286+
expect(result.getDayOfWeek()).toBe(6); // Saturday
287+
expect(result.getHour()).toBe(23);
288+
expect(result.getMinute()).toBe(59);
289+
});
290+
284291
it('should get end of month', () => {
285292
const result = testDate.endOfMonth();
286293
expect(result.getDay()).toBe(31); // May has 31 days
@@ -293,6 +300,13 @@ describe('DateBuilder', () => {
293300
expect(result.getDay()).toBe(31);
294301
});
295302

303+
it('should get end of quarter', () => {
304+
const result = testDate.endOfQuarter();
305+
expect(result.getMonth()).toBe(6); // May is in Q2, so quarter end is June
306+
expect(result.getDay()).toBe(30);
307+
expect(result.getHour()).toBe(23);
308+
});
309+
296310
it('should handle chaining end of periods', () => {
297311
const result = testDate.endOfMonth().endOfDay();
298312
expect(result.getDay()).toBe(31); // May has 31 days
@@ -335,6 +349,12 @@ describe('DateBuilder', () => {
335349
expect(baseDate.isBetween(baseDate, laterDate, false)).toBe(false);
336350
});
337351

352+
it('should accept Date instances in isBetween', () => {
353+
const start = new Date('2024-05-10');
354+
const end = new Date('2024-05-20');
355+
expect(baseDate.isBetween(start, end)).toBe(true);
356+
});
357+
338358
it('should check isWeekend', () => {
339359
const saturday = DateBuilder.of(2024, 5, 18); // May 18, 2024 is Saturday
340360
const sunday = DateBuilder.of(2024, 5, 19); // May 19, 2024 is Sunday

tests/utils/date.utils.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,27 @@ describe('date.utils', () => {
140140
const future = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
141141
expect(formatRelativeTime(future, now)).toContain('day');
142142
});
143+
144+
it('uses default now and supports numeric input', () => {
145+
const thirtySecondsAgo = Date.now() - 30 * 1000;
146+
expect(formatRelativeTime(thirtySecondsAgo)).toContain('second');
147+
});
148+
149+
it('returns minute/hour/month/year buckets', () => {
150+
const now = new Date('2024-01-01T00:00:00Z');
151+
152+
const minutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
153+
expect(formatRelativeTime(minutesAgo, now, 'en-US')).toContain('minute');
154+
155+
const hoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000);
156+
expect(formatRelativeTime(hoursAgo, now, 'en-US')).toContain('hour');
157+
158+
const monthsAgo = new Date(now.getTime() - 70 * 24 * 60 * 60 * 1000);
159+
expect(formatRelativeTime(monthsAgo, now, 'en-US')).toContain('month');
160+
161+
const yearsAgo = new Date(now.getTime() - 400 * 24 * 60 * 60 * 1000);
162+
expect(formatRelativeTime(yearsAgo, now, 'en-US')).toContain('year');
163+
});
143164
});
144165

145166
describe('parseDate', () => {
@@ -566,6 +587,25 @@ describe('date.utils', () => {
566587
const end = new Date('2023-05-20');
567588
expect(isBetween(date, start, end, false)).toBe(false);
568589
});
590+
591+
it('supports numeric timestamps and inclusive boundaries', () => {
592+
const start = new Date('2023-05-10').getTime();
593+
const end = new Date('2023-05-20').getTime();
594+
const boundary = start;
595+
596+
expect(isBetween(boundary, start, end, true)).toBe(true);
597+
expect(isBetween(boundary, start, end, false)).toBe(false);
598+
});
599+
600+
it('handles mixed date/number inputs consistently', () => {
601+
const dateNumber = new Date('2023-05-15').getTime();
602+
const startDate = new Date('2023-05-10');
603+
const endDate = new Date('2023-05-20');
604+
605+
expect(isBetween(dateNumber, startDate, endDate.getTime(), true)).toBe(true);
606+
expect(isBetween(dateNumber, startDate.getTime(), endDate, true)).toBe(true);
607+
expect(isBetween(new Date('2023-05-15'), startDate.getTime(), endDate.getTime(), true)).toBe(true);
608+
});
569609
});
570610

571611
describe('isLeapYear', () => {
@@ -820,6 +860,12 @@ describe('date.utils', () => {
820860
const result = addYears(timestamp, 5);
821861
expect(result).toBeInstanceOf(Date);
822862
});
863+
864+
it('handles zero-year addition and preserves date value', () => {
865+
const date = new Date('2024-03-01T12:00:00Z');
866+
const result = addYears(date, 0);
867+
expect(result.getTime()).toBe(date.getTime());
868+
});
823869
});
824870

825871
describe('quarterOf', () => {

tests/utils/logger.utils.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,34 @@ describe('LoggerUtils', () => {
3939
delete (loggerUtils._globalThis as any)[Symbol.for('logger')];
4040
});
4141

42+
it('exports default sensitive fields as a non-empty array', () => {
43+
expect(Array.isArray(loggerUtils.defaultSensitiveFields)).toBe(true);
44+
expect(loggerUtils.defaultSensitiveFields.length).toBeGreaterThan(0);
45+
expect(loggerUtils.defaultSensitiveFields).toContain('password');
46+
});
47+
48+
it('exposes default redact censor and allows replacing/restoring it', () => {
49+
const original = loggerUtils.getRedactCensor();
50+
const replacement = jest.fn().mockReturnValue('MASKED');
51+
52+
try {
53+
loggerUtils.setRedactCensor(replacement);
54+
expect(loggerUtils.getRedactCensor()).toBe(replacement);
55+
expect(loggerUtils.redact('value', ['path'])).toBe('MASKED');
56+
} finally {
57+
loggerUtils.setRedactCensor(original);
58+
}
59+
});
60+
4261
describe('getLogger', () => {
62+
it('returns a fresh non-global logger when newInstance is true', () => {
63+
(ContextStore.get as jest.Mock).mockReturnValue(undefined);
64+
const logger = loggerUtils.getLogger(true);
65+
66+
expect(logger).toBe(mockLogger);
67+
expect((loggerUtils._globalThis as any)[Symbol.for('logger')]).toBeUndefined();
68+
});
69+
4370
it('returns logger from context if available', () => {
4471
(ContextStore.get as jest.Mock).mockReturnValue(mockLogger);
4572
const logger = loggerUtils.getLogger();
@@ -160,6 +187,68 @@ describe('LoggerUtils', () => {
160187
);
161188
expect(logger).toBe(mockLogger);
162189
});
190+
191+
it('uses multistream transport when pretty and file logging are enabled', () => {
192+
const transportMock = jest.fn().mockReturnValue('transported');
193+
(pino as any).transport = transportMock;
194+
195+
setCatbeeGlobalConfig({
196+
logger: {
197+
pretty: true,
198+
dir: 'logs',
199+
level: 'info',
200+
colorize: true,
201+
singleLine: true
202+
}
203+
});
204+
205+
loggerUtils.getLogger();
206+
207+
expect(transportMock).toHaveBeenCalledWith(
208+
expect.objectContaining({
209+
targets: expect.arrayContaining([
210+
expect.objectContaining({ target: 'pino-pretty' }),
211+
expect.objectContaining({ target: 'pino/file' })
212+
])
213+
})
214+
);
215+
});
216+
217+
it('uses file-only transport when only logger dir is configured', () => {
218+
const transportMock = jest.fn().mockReturnValue('transported');
219+
(pino as any).transport = transportMock;
220+
221+
setCatbeeGlobalConfig({
222+
logger: {
223+
pretty: false,
224+
dir: 'logs'
225+
}
226+
});
227+
228+
loggerUtils.getLogger();
229+
230+
expect(transportMock).toHaveBeenCalledWith(
231+
expect.objectContaining({
232+
target: 'pino/file',
233+
options: expect.objectContaining({ destination: 'logs/app.log' })
234+
})
235+
);
236+
});
237+
238+
it('uses plain pino without transport when pretty and file logging are disabled', () => {
239+
(pino as any).transport = jest.fn().mockReturnValue('transported');
240+
setCatbeeGlobalConfig({
241+
logger: {
242+
pretty: false,
243+
dir: ''
244+
}
245+
});
246+
247+
loggerUtils.getLogger();
248+
249+
expect((pino as any).transport).not.toHaveBeenCalled();
250+
expect(pino).toHaveBeenCalledWith(expect.objectContaining({ level: 'info' }));
251+
});
163252
});
164253

165254
describe('redactCensor', () => {
@@ -206,6 +295,18 @@ describe('LoggerUtils', () => {
206295
expect(loggerUtils.getRedactCensor()({ key: 'value' } as any, ['object'])).toBe('***');
207296
});
208297

298+
it('should return original value for non-sensitive custom top-level path', () => {
299+
expect(loggerUtils.getRedactCensor()('trace-123', ['meta', 'traceId'])).toBe('trace-123');
300+
});
301+
302+
it('should fallback to "***" when authorization value is empty', () => {
303+
expect(loggerUtils.getRedactCensor()('', ['req', 'headers', 'authorization'])).toBe('***');
304+
});
305+
306+
it('should ignore non-string path segments while evaluating redaction', () => {
307+
expect(loggerUtils.getRedactCensor()('safe', ['meta', 123 as unknown as string] as string[])).toBe('safe');
308+
});
309+
209310
describe('setRedactCensor and getRedactCensor', () => {
210311
it('should allow setting and getting a custom redaction function', () => {
211312
// Save original censor to restore after test

0 commit comments

Comments
 (0)