Add tests for Cache, MemoryLimitedMap, and other util.js coverage gaps (#5365)

a45ec30cf0e9bc7468b6351719330ed15bf9e2ce

Tony Gies <tgies@tgies.net>

Signed
1 files changed, +268 -0Showing whitespace changes
tests/util-pure.test.js+268 -0
@@ -23,6 +23,14 @@ import {
2323 isPathUnderParent,
2424 isFileURL,
2525 getRequestURL,
26+ delay,
27+ formatBytes,
28+ sanitizeSafeCharacterReplacements,
29+ generateTimestamp,
30+ mergeObjectWithYaml,
31+ excludeKeysByYaml,
32+ Cache,
33+ MemoryLimitedMap,
2634} from '../src/util';
2735
2836describe('keyToEnv', () => {
@@ -419,3 +427,263 @@ describe('getRequestURL', () => {
419427 expect(() => getRequestURL(42)).toThrow(TypeError);
420428 });
421429});
430+
431+describe('delay', () => {
432+ test('should resolve after the specified time', async () => {
433+ jest.useFakeTimers();
434+ let resolved = false;
435+ delay(50).then(() => { resolved = true; });
436+ expect(resolved).toBe(false);
437+ jest.advanceTimersByTime(50);
438+ await Promise.resolve();
439+ expect(resolved).toBe(true);
440+ jest.useRealTimers();
441+ });
442+
443+ test('should return a promise', () => {
444+ jest.useFakeTimers();
445+ const result = delay(0);
446+ expect(result).toBeInstanceOf(Promise);
447+ jest.useRealTimers();
448+ });
449+});
450+
451+describe('formatBytes', () => {
452+ test('should format bytes to human-readable string', () => {
453+ expect(formatBytes(0)).toBe('0B');
454+ expect(formatBytes(1024)).toBe('1KB');
455+ expect(formatBytes(1048576)).toBe('1MB');
456+ });
457+
458+ test('should return empty string for null/undefined', () => {
459+ expect(formatBytes(null)).toBe('');
460+ expect(formatBytes(undefined)).toBe('');
461+ });
462+});
463+
464+describe('sanitizeSafeCharacterReplacements', () => {
465+ test('should always return underscore', () => {
466+ expect(sanitizeSafeCharacterReplacements('/')).toBe('_');
467+ expect(sanitizeSafeCharacterReplacements('\\')).toBe('_');
468+ expect(sanitizeSafeCharacterReplacements(':')).toBe('_');
469+ expect(sanitizeSafeCharacterReplacements('')).toBe('_');
470+ });
471+});
472+
473+describe('generateTimestamp', () => {
474+ test('should return YYYYMMDD-HHMMSS format for a known date', () => {
475+ jest.useFakeTimers();
476+ jest.setSystemTime(new Date('2025-07-15T09:30:45'));
477+ expect(generateTimestamp()).toBe('20250715-093045');
478+ jest.useRealTimers();
479+ });
480+});
481+
482+describe('mergeObjectWithYaml', () => {
483+ test('should merge a YAML object into the target', () => {
484+ const obj = { a: 1 };
485+ mergeObjectWithYaml(obj, 'b: 2\nc: 3');
486+ expect(obj).toEqual({ a: 1, b: 2, c: 3 });
487+ });
488+
489+ test('should merge a YAML array of objects into the target', () => {
490+ const obj = { a: 1 };
491+ mergeObjectWithYaml(obj, '- b: 2\n- c: 3');
492+ expect(obj).toEqual({ a: 1, b: 2, c: 3 });
493+ });
494+
495+ test('should override existing keys', () => {
496+ const obj = { a: 1 };
497+ mergeObjectWithYaml(obj, 'a: 99');
498+ expect(obj.a).toBe(99);
499+ });
500+
501+ test('should do nothing for empty/falsy yamlString', () => {
502+ const obj = { a: 1 };
503+ mergeObjectWithYaml(obj, '');
504+ mergeObjectWithYaml(obj, null);
505+ mergeObjectWithYaml(obj, undefined);
506+ expect(obj).toEqual({ a: 1 });
507+ });
508+
509+ test('should not throw on invalid YAML', () => {
510+ const obj = { a: 1 };
511+ expect(() => mergeObjectWithYaml(obj, '{{{')).not.toThrow();
512+ expect(obj).toEqual({ a: 1 });
513+ });
514+
515+ test('should skip non-object items in YAML array', () => {
516+ const obj = { a: 1 };
517+ mergeObjectWithYaml(obj, '- hello\n- b: 2');
518+ expect(obj).toEqual({ a: 1, b: 2 });
519+ });
520+});
521+
522+describe('excludeKeysByYaml', () => {
523+ test('should delete keys listed in a YAML array', () => {
524+ const obj = { a: 1, b: 2, c: 3 };
525+ excludeKeysByYaml(obj, '- a\n- c');
526+ expect(obj).toEqual({ b: 2 });
527+ });
528+
529+ test('should delete keys from a YAML object', () => {
530+ const obj = { a: 1, b: 2 };
531+ excludeKeysByYaml(obj, 'a: whatever\nb: whatever');
532+ expect(obj).toEqual({});
533+ });
534+
535+ test('should delete a single string key', () => {
536+ const obj = { a: 1, b: 2 };
537+ excludeKeysByYaml(obj, 'a');
538+ expect(obj).toEqual({ b: 2 });
539+ });
540+
541+ test('should do nothing for empty/falsy yamlString', () => {
542+ const obj = { a: 1 };
543+ excludeKeysByYaml(obj, '');
544+ excludeKeysByYaml(obj, null);
545+ expect(obj).toEqual({ a: 1 });
546+ });
547+
548+ test('should not throw on invalid YAML', () => {
549+ const obj = { a: 1 };
550+ expect(() => excludeKeysByYaml(obj, '{{{')).not.toThrow();
551+ expect(obj).toEqual({ a: 1 });
552+ });
553+});
554+
555+describe('Cache', () => {
556+ test('should store and retrieve values', () => {
557+ const cache = new Cache(1000);
558+ cache.set('key', 'value');
559+ expect(cache.get('key')).toBe('value');
560+ });
561+
562+ test('should return null for missing keys', () => {
563+ const cache = new Cache(1000);
564+ expect(cache.get('missing')).toBeNull();
565+ });
566+
567+ test('should return null for expired entries', () => {
568+ jest.useFakeTimers();
569+ const cache = new Cache(10);
570+ cache.set('key', 'value');
571+ jest.advanceTimersByTime(20);
572+ expect(cache.get('key')).toBeNull();
573+ jest.useRealTimers();
574+ });
575+
576+ test('should remove entries', () => {
577+ const cache = new Cache(1000);
578+ cache.set('key', 'value');
579+ cache.remove('key');
580+ expect(cache.get('key')).toBeNull();
581+ });
582+
583+ test('should clear all entries', () => {
584+ const cache = new Cache(1000);
585+ cache.set('a', 1);
586+ cache.set('b', 2);
587+ cache.clear();
588+ expect(cache.get('a')).toBeNull();
589+ expect(cache.get('b')).toBeNull();
590+ });
591+});
592+
593+describe('MemoryLimitedMap', () => {
594+ test('should store and retrieve values', () => {
595+ const map = new MemoryLimitedMap('1 MB');
596+ map.set('key', 'value');
597+ expect(map.get('key')).toBe('value');
598+ expect(map.has('key')).toBe(true);
599+ });
600+
601+ test('should reject non-string keys and values', () => {
602+ const map = new MemoryLimitedMap('1 MB');
603+ map.set(123, 'value');
604+ map.set('key', 123);
605+ expect(map.size()).toBe(0);
606+ });
607+
608+ test('should evict oldest entries when memory limit is reached', () => {
609+ // 20 bytes capacity = 10 chars (2 bytes per char)
610+ const map = new MemoryLimitedMap('20B');
611+ map.set('a', '12345'); // 10 bytes
612+ map.set('b', '12345'); // 10 bytes, fills capacity
613+ map.set('c', '12345'); // 10 bytes, should evict 'a'
614+ expect(map.has('a')).toBe(false);
615+ expect(map.has('b')).toBe(true);
616+ expect(map.has('c')).toBe(true);
617+ });
618+
619+ test('should reject values larger than max memory', () => {
620+ const map = new MemoryLimitedMap('10B');
621+ map.set('key', '123456'); // 12 bytes > 10 byte limit
622+ expect(map.has('key')).toBe(false);
623+ });
624+
625+ test('should do nothing when maxMemory is 0', () => {
626+ const map = new MemoryLimitedMap('0B');
627+ map.set('key', 'value');
628+ expect(map.size()).toBe(0);
629+ });
630+
631+ test('should track memory usage', () => {
632+ const map = new MemoryLimitedMap('1 MB');
633+ map.set('key', 'hello'); // 10 bytes
634+ expect(map.totalMemory()).toBe(10);
635+ });
636+
637+ test('should update memory when overwriting a key', () => {
638+ const map = new MemoryLimitedMap('1 MB');
639+ map.set('key', 'hi'); // 4 bytes
640+ map.set('key', 'hello'); // 10 bytes
641+ expect(map.totalMemory()).toBe(10);
642+ expect(map.get('key')).toBe('hello');
643+ });
644+
645+ test('should delete entries and free memory', () => {
646+ const map = new MemoryLimitedMap('1 MB');
647+ map.set('key', 'hello');
648+ expect(map.delete('key')).toBe(true);
649+ expect(map.totalMemory()).toBe(0);
650+ expect(map.has('key')).toBe(false);
651+ });
652+
653+ test('should return false when deleting non-existent key', () => {
654+ const map = new MemoryLimitedMap('1 MB');
655+ expect(map.delete('nope')).toBe(false);
656+ });
657+
658+ test('should clear all entries and reset memory', () => {
659+ const map = new MemoryLimitedMap('1 MB');
660+ map.set('a', 'hello');
661+ map.set('b', 'world');
662+ map.clear();
663+ expect(map.size()).toBe(0);
664+ expect(map.totalMemory()).toBe(0);
665+ });
666+
667+ test('should iterate with forEach', () => {
668+ const map = new MemoryLimitedMap('1 MB');
669+ map.set('a', '1');
670+ map.set('b', '2');
671+ const entries = [];
672+ map.forEach((value, key) => entries.push([key, value]));
673+ expect(entries).toEqual([['a', '1'], ['b', '2']]);
674+ });
675+
676+ test('should expose keys and values iterators', () => {
677+ const map = new MemoryLimitedMap('1 MB');
678+ map.set('a', '1');
679+ map.set('b', '2');
680+ expect([...map.keys()]).toEqual(['a', 'b']);
681+ expect([...map.values()]).toEqual(['1', '2']);
682+ });
683+
684+ test('estimateStringSize should return 2 bytes per character', () => {
685+ expect(MemoryLimitedMap.estimateStringSize('hello')).toBe(10);
686+ expect(MemoryLimitedMap.estimateStringSize('')).toBe(0);
687+ expect(MemoryLimitedMap.estimateStringSize(null)).toBe(0);
688+ });
689+});