Skip to content

Commit 59a4f6a

Browse files
committed
test: update UTs to improve coverage
1 parent 47a655f commit 59a4f6a

9 files changed

Lines changed: 951 additions & 24 deletions

src/utils/date.utils.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,29 @@ export interface DateFormatOptions {
5555
*/
5656
export function formatDate(date: Date | number, options: DateFormatOptions = {}): string {
5757
const { format = 'yyyy-MM-dd', locale, timeZone } = options;
58+
const d = date instanceof Date ? date : new Date(date);
5859

59-
// Use Intl.DateTimeFormat for complex formatting
60+
// Custom formatter for common patterns
61+
if (format === 'yyyy-MM-dd') {
62+
const yyyy = d.getFullYear();
63+
const mm = String(d.getMonth() + 1).padStart(2, '0');
64+
const dd = String(d.getDate()).padStart(2, '0');
65+
return `${yyyy}-${mm}-${dd}`;
66+
}
67+
if (format === 'yyyy-MM-dd HH:mm:ss') {
68+
const yyyy = d.getFullYear();
69+
const mm = String(d.getMonth() + 1).padStart(2, '0');
70+
const dd = String(d.getDate()).padStart(2, '0');
71+
const HH = String(d.getHours()).padStart(2, '0');
72+
const MM = String(d.getMinutes()).padStart(2, '0');
73+
const SS = String(d.getSeconds()).padStart(2, '0');
74+
return `${yyyy}-${mm}-${dd} ${HH}:${MM}:${SS}`;
75+
}
6076
if (format === 'relative') {
6177
return formatRelativeTime(date);
6278
}
6379

64-
// Handle standard date formatting patterns
65-
const d = date instanceof Date ? date : new Date(date);
66-
67-
// Use built-in Intl.DateTimeFormat for locale-aware formatting
80+
// Fallback to Intl.DateTimeFormat for other formats
6881
return new Intl.DateTimeFormat(locale, {
6982
timeZone,
7083
year: format.includes('yyyy') || format.includes('y') ? 'numeric' : undefined,
@@ -228,9 +241,15 @@ export function addToDate(
228241
case 'days':
229242
d.setDate(d.getDate() + amount);
230243
break;
231-
case 'months':
232-
d.setMonth(d.getMonth() + amount);
244+
case 'months': {
245+
const origDate = d.getDate();
246+
const origMonth = d.getMonth();
247+
d.setDate(1); // Prevent overflow
248+
d.setMonth(origMonth + amount);
249+
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
250+
d.setDate(Math.min(origDate, lastDay));
233251
break;
252+
}
234253
case 'years':
235254
d.setFullYear(d.getFullYear() + amount);
236255
break;

src/utils/performance.utils.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ export function timeSync<T>(fn: () => T, options: TimingOptions = {}): { result:
108108
case 'error':
109109
logger.error({ timing }, message);
110110
break;
111+
default:
112+
logger.debug({ timing }, message);
113+
break;
111114
}
112115
}
113116

@@ -173,6 +176,9 @@ export async function timeAsync<T>(
173176
case 'error':
174177
logger.error({ timing }, message);
175178
break;
179+
default:
180+
logger.debug({ timing }, message);
181+
break;
176182
}
177183
}
178184

@@ -196,13 +202,26 @@ export async function timeAsync<T>(
196202
* ```
197203
*/
198204
export function timed(options: TimingOptions = {}) {
199-
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
200-
const originalMethod = descriptor.value;
205+
return function (target: any, propertyKeyOrContext: string | symbol | any, descriptor?: PropertyDescriptor): void {
206+
let propertyKey: string | symbol;
207+
let actualDescriptor: PropertyDescriptor;
208+
209+
// ESNext decorator: second argument is context object
210+
if (typeof propertyKeyOrContext === 'object' && propertyKeyOrContext !== null && 'name' in propertyKeyOrContext) {
211+
propertyKey = propertyKeyOrContext.name;
212+
actualDescriptor = descriptor ?? Object.getOwnPropertyDescriptor(target, propertyKey)!;
213+
} else {
214+
// Legacy decorator: second argument is property key
215+
propertyKey = propertyKeyOrContext;
216+
actualDescriptor = descriptor ?? Object.getOwnPropertyDescriptor(target, propertyKey)!;
217+
}
218+
219+
const originalMethod = actualDescriptor.value;
201220

202-
descriptor.value = function (...args: any[]) {
221+
actualDescriptor.value = function (...args: any[]) {
203222
const methodOptions = {
204223
...options,
205-
label: options.label || `${target.constructor.name}.${propertyKey}`
224+
label: options.label || `${target.constructor.name}.${String(propertyKey)}`
206225
};
207226

208227
if (originalMethod.constructor.name === 'AsyncFunction') {
@@ -212,7 +231,13 @@ export function timed(options: TimingOptions = {}) {
212231
}
213232
};
214233

215-
return descriptor;
234+
// For legacy decorators, assign the new descriptor
235+
if (descriptor) {
236+
descriptor.value = actualDescriptor.value;
237+
} else {
238+
Object.defineProperty(target, propertyKey, actualDescriptor);
239+
}
240+
// No return needed for method decorators
216241
};
217242
}
218243

src/utils/stream.utils.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,14 @@ export function createBatchStream(size: number, options: { objectMode?: boolean
154154
buffers.push(buffer);
155155
bufferSize += buffer.length;
156156

157-
if (bufferSize >= size) {
158-
const result = Buffer.concat(buffers);
159-
this.push(result.slice(0, size));
160-
161-
// Keep the remainder for the next batch
162-
const remainder = result.slice(size);
163-
buffers = remainder.length > 0 ? [remainder] : [];
164-
bufferSize = remainder.length;
157+
// Emit batches of exact size
158+
let combined = Buffer.concat(buffers, bufferSize);
159+
while (combined.length >= size) {
160+
this.push(combined.slice(0, size));
161+
combined = combined.slice(size);
165162
}
163+
buffers = combined.length > 0 ? [combined] : [];
164+
bufferSize = combined.length;
166165
}
167166

168167
callback();
@@ -171,10 +170,16 @@ export function createBatchStream(size: number, options: { objectMode?: boolean
171170
flush(callback) {
172171
if (objectMode && batch.length > 0) {
173172
this.push(batch);
174-
} else if (!objectMode && buffers.length > 0) {
175-
this.push(Buffer.concat(buffers));
173+
} else if (!objectMode && buffers.length > 0 && bufferSize > 0) {
174+
let combined = Buffer.concat(buffers, bufferSize);
175+
while (combined.length >= size) {
176+
this.push(combined.slice(0, size));
177+
combined = combined.slice(size);
178+
}
179+
if (combined.length > 0) {
180+
this.push(combined);
181+
}
176182
}
177-
178183
callback();
179184
}
180185
});

src/utils/type.utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export function isPrimitiveType(
5656
return value === undefined;
5757
}
5858

59+
if (type === 'object') {
60+
// Only plain objects, not arrays or null
61+
return typeof value === 'object' && value !== null && !Array.isArray(value);
62+
}
63+
5964
return typeof value === type;
6065
}
6166

tests/utils/async.utils.test.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import {
1313
abortable,
1414
createDeferred,
1515
waterfall,
16-
rateLimit
16+
rateLimit,
17+
circuitBreaker,
18+
CircuitBreakerOpenError,
19+
runWithConcurrency
1720
} from '../../src/utils/async.utils';
1821

1922
describe('sleep', () => {
@@ -351,3 +354,121 @@ describe('rateLimit', () => {
351354
jest.useRealTimers();
352355
});
353356
});
357+
358+
describe('circuitBreaker', () => {
359+
it('allows calls when closed and resets failures on success', async () => {
360+
let count = 0;
361+
const fn = async () => ++count;
362+
const breaker = circuitBreaker(fn, { failureThreshold: 2, resetTimeout: 50 });
363+
expect(await breaker()).toBe(1);
364+
expect(await breaker()).toBe(2);
365+
});
366+
367+
it('opens circuit after failures and blocks calls', async () => {
368+
let fail = true;
369+
const fn = async () => {
370+
if (fail) throw new Error('fail');
371+
return 'ok';
372+
};
373+
let opened = false;
374+
const breaker = circuitBreaker(fn, {
375+
failureThreshold: 2,
376+
resetTimeout: 50,
377+
onOpen: () => {
378+
opened = true;
379+
}
380+
});
381+
await expect(breaker()).rejects.toThrow('fail');
382+
await expect(breaker()).rejects.toThrow('fail');
383+
expect(opened).toBe(true);
384+
await expect(breaker()).rejects.toBeInstanceOf(CircuitBreakerOpenError);
385+
});
386+
387+
it('moves to half-open after resetTimeout and closes on success', async () => {
388+
let fail = true;
389+
let halfOpen = false,
390+
closed = false;
391+
const fn = async () => {
392+
if (fail) throw new Error('fail');
393+
return 'ok';
394+
};
395+
const breaker = circuitBreaker(fn, {
396+
failureThreshold: 1,
397+
resetTimeout: 10,
398+
successThreshold: 1,
399+
onHalfOpen: () => {
400+
halfOpen = true;
401+
},
402+
onClose: () => {
403+
closed = true;
404+
}
405+
});
406+
await expect(breaker()).rejects.toThrow('fail');
407+
await expect(breaker()).rejects.toBeInstanceOf(CircuitBreakerOpenError);
408+
await new Promise(res => setTimeout(res, 12));
409+
fail = false;
410+
expect(await breaker()).toBe('ok');
411+
expect(halfOpen).toBe(true);
412+
expect(closed).toBe(true);
413+
});
414+
415+
it('throws CircuitBreakerOpenError when open', async () => {
416+
const fn = async () => {
417+
throw new Error('fail');
418+
};
419+
const breaker = circuitBreaker(fn, { failureThreshold: 1, resetTimeout: 100 });
420+
await expect(breaker()).rejects.toThrow('fail');
421+
await expect(breaker()).rejects.toBeInstanceOf(CircuitBreakerOpenError);
422+
});
423+
});
424+
425+
describe('runWithConcurrency', () => {
426+
it('runs tasks with concurrency', async () => {
427+
const order: number[] = [];
428+
const tasks = [1, 2, 3, 4, 5].map(n => async () => {
429+
await sleep(10);
430+
order.push(n);
431+
return n;
432+
});
433+
const result = await runWithConcurrency(tasks, { concurrency: 2 });
434+
expect(result.sort()).toEqual([1, 2, 3, 4, 5]);
435+
expect(order.length).toBe(5);
436+
});
437+
438+
it('calls onProgress callback', async () => {
439+
const progress: number[] = [];
440+
const tasks = [1, 2, 3].map(n => async () => n);
441+
await runWithConcurrency(tasks, {
442+
concurrency: 2,
443+
onProgress: (completed, _total) => progress.push(completed)
444+
});
445+
expect(progress).toEqual([1, 2, 3]);
446+
});
447+
448+
it('aborts if signal is triggered', async () => {
449+
const ctrl = new AbortController();
450+
const tasks = [
451+
async () => {
452+
await sleep(10);
453+
return 1;
454+
},
455+
async () => {
456+
await sleep(10);
457+
return 2;
458+
}
459+
];
460+
setTimeout(() => ctrl.abort(), 5);
461+
await expect(runWithConcurrency(tasks, { concurrency: 1, signal: ctrl.signal })).rejects.toThrow('Aborted');
462+
});
463+
464+
it('returns empty array for no tasks', async () => {
465+
const result = await runWithConcurrency([], { concurrency: 2 });
466+
expect(result).toEqual([]);
467+
});
468+
469+
it('throws if already aborted', async () => {
470+
const ctrl = new AbortController();
471+
ctrl.abort();
472+
await expect(runWithConcurrency([async () => 1], { signal: ctrl.signal })).rejects.toThrow('Aborted');
473+
});
474+
});

0 commit comments

Comments
 (0)