| 1 | import { describe, test, expect, jest } from '@jest/globals'; |
| 2 | import { |
| 3 | keyToEnv, |
| 4 | getBasicAuthHeader, |
| 5 | getHexString, |
| 6 | normalizeZipEntryPath, |
| 7 | deepMerge, |
| 8 | uuidv4, |
| 9 | humanizedDateTime, |
| 10 | tryParse, |
| 11 | clientRelativePath, |
| 12 | getUniqueName, |
| 13 | removeFileExtension, |
| 14 | removeColorFormatting, |
| 15 | getSeparator, |
| 16 | isValidUrl, |
| 17 | urlHostnameToIPv6, |
| 18 | toBoolean, |
| 19 | stringToBool, |
| 20 | trimV1, |
| 21 | trimTrailingSlash, |
| 22 | mutateJsonString, |
| 23 | isPathUnderParent, |
| 24 | isFileURL, |
| 25 | getRequestURL, |
| 26 | delay, |
| 27 | formatBytes, |
| 28 | sanitizeSafeCharacterReplacements, |
| 29 | generateTimestamp, |
| 30 | mergeObjectWithYaml, |
| 31 | excludeKeysByYaml, |
| 32 | Cache, |
| 33 | MemoryLimitedMap, |
| 34 | } from '../src/util'; |
| 35 | |
| 36 | describe('keyToEnv', () => { |
| 37 | test('should convert dotted key to env var format', () => { |
| 38 | expect(keyToEnv('extensions.models.speechToText')).toBe('SILLYTAVERN_EXTENSIONS_MODELS_SPEECHTOTEXT'); |
| 39 | }); |
| 40 | |
| 41 | test('should handle simple key without dots', () => { |
| 42 | expect(keyToEnv('port')).toBe('SILLYTAVERN_PORT'); |
| 43 | }); |
| 44 | |
| 45 | test('should coerce non-string input via String()', () => { |
| 46 | expect(keyToEnv(42)).toBe('SILLYTAVERN_42'); |
| 47 | }); |
| 48 | }); |
| 49 | |
| 50 | describe('getBasicAuthHeader', () => { |
| 51 | test('should return a valid Basic auth header', () => { |
| 52 | expect(getBasicAuthHeader('user:pass')).toBe('Basic dXNlcjpwYXNz'); |
| 53 | }); |
| 54 | |
| 55 | test('should handle empty string', () => { |
| 56 | expect(getBasicAuthHeader('')).toBe('Basic '); |
| 57 | }); |
| 58 | }); |
| 59 | |
| 60 | describe('getHexString', () => { |
| 61 | test('should return a string of the requested length', () => { |
| 62 | expect(getHexString(8)).toHaveLength(8); |
| 63 | expect(getHexString(32)).toHaveLength(32); |
| 64 | }); |
| 65 | |
| 66 | test('should only contain hex characters', () => { |
| 67 | expect(getHexString(64)).toMatch(/^[0-9a-f]+$/); |
| 68 | }); |
| 69 | |
| 70 | test('should return empty string for length 0', () => { |
| 71 | expect(getHexString(0)).toBe(''); |
| 72 | }); |
| 73 | }); |
| 74 | |
| 75 | describe('normalizeZipEntryPath', () => { |
| 76 | test('should normalize backslashes to forward slashes', () => { |
| 77 | expect(normalizeZipEntryPath('foo\\bar\\baz.txt')).toBe('foo/bar/baz.txt'); |
| 78 | }); |
| 79 | |
| 80 | test('should strip leading ./', () => { |
| 81 | expect(normalizeZipEntryPath('./file.txt')).toBe('file.txt'); |
| 82 | }); |
| 83 | |
| 84 | test('should strip leading /', () => { |
| 85 | expect(normalizeZipEntryPath('/absolute/path.txt')).toBe('absolute/path.txt'); |
| 86 | }); |
| 87 | |
| 88 | test('should reject path traversal', () => { |
| 89 | expect(normalizeZipEntryPath('../etc/passwd')).toBeNull(); |
| 90 | }); |
| 91 | |
| 92 | test('should reject non-string input', () => { |
| 93 | expect(normalizeZipEntryPath(42)).toBeNull(); |
| 94 | expect(normalizeZipEntryPath(null)).toBeNull(); |
| 95 | }); |
| 96 | |
| 97 | test('should reject empty or whitespace-only string', () => { |
| 98 | expect(normalizeZipEntryPath('')).toBeNull(); |
| 99 | expect(normalizeZipEntryPath(' ')).toBeNull(); |
| 100 | }); |
| 101 | }); |
| 102 | |
| 103 | describe('deepMerge', () => { |
| 104 | test('should merge flat objects', () => { |
| 105 | expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); |
| 106 | }); |
| 107 | |
| 108 | test('should recursively merge nested objects', () => { |
| 109 | const target = { nested: { a: 1, b: 2 } }; |
| 110 | const source = { nested: { b: 3, c: 4 } }; |
| 111 | expect(deepMerge(target, source)).toEqual({ nested: { a: 1, b: 3, c: 4 } }); |
| 112 | }); |
| 113 | |
| 114 | test('should override primitives with source values', () => { |
| 115 | expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); |
| 116 | }); |
| 117 | |
| 118 | test('should not mutate original objects', () => { |
| 119 | const target = { a: { x: 1 } }; |
| 120 | const source = { a: { y: 2 } }; |
| 121 | const result = deepMerge(target, source); |
| 122 | expect(target).toEqual({ a: { x: 1 } }); |
| 123 | expect(result).toEqual({ a: { x: 1, y: 2 } }); |
| 124 | }); |
| 125 | |
| 126 | test('should handle empty source', () => { |
| 127 | expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 }); |
| 128 | }); |
| 129 | }); |
| 130 | |
| 131 | describe('uuidv4', () => { |
| 132 | test('should return a valid UUIDv4 format', () => { |
| 133 | const uuid = uuidv4(); |
| 134 | expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); |
| 135 | }); |
| 136 | |
| 137 | test('should return unique values', () => { |
| 138 | const a = uuidv4(); |
| 139 | const b = uuidv4(); |
| 140 | expect(a).not.toBe(b); |
| 141 | }); |
| 142 | }); |
| 143 | |
| 144 | describe('humanizedDateTime', () => { |
| 145 | test('should format a known timestamp correctly', () => { |
| 146 | // 2024-01-15 09:05:03.007 UTC |
| 147 | const timestamp = Date.UTC(2024, 0, 15, 9, 5, 3, 7); |
| 148 | const result = humanizedDateTime(timestamp); |
| 149 | // The output uses local time, so just check the format pattern |
| 150 | expect(result).toMatch(/^\d{4}-\d{2}-\d{2}@\d{2}h\d{2}m\d{2}s\d{3}ms$/); |
| 151 | }); |
| 152 | }); |
| 153 | |
| 154 | describe('tryParse', () => { |
| 155 | test('should parse valid JSON', () => { |
| 156 | expect(tryParse('{"a":1}')).toEqual({ a: 1 }); |
| 157 | }); |
| 158 | |
| 159 | test('should parse JSON array', () => { |
| 160 | expect(tryParse('[1,2,3]')).toEqual([1, 2, 3]); |
| 161 | }); |
| 162 | |
| 163 | test('should return undefined for invalid JSON', () => { |
| 164 | expect(tryParse('not json')).toBeUndefined(); |
| 165 | }); |
| 166 | |
| 167 | test('should return undefined for empty string', () => { |
| 168 | expect(tryParse('')).toBeUndefined(); |
| 169 | }); |
| 170 | }); |
| 171 | |
| 172 | describe('clientRelativePath', () => { |
| 173 | test('should strip the root prefix and use forward slashes', () => { |
| 174 | expect(clientRelativePath('/data/user', '/data/user/images/pic.png')).toBe('/images/pic.png'); |
| 175 | }); |
| 176 | |
| 177 | test('should throw if path does not start with root', () => { |
| 178 | expect(() => clientRelativePath('/data/user', '/other/path')).toThrow(); |
| 179 | }); |
| 180 | }); |
| 181 | |
| 182 | describe('getUniqueName', () => { |
| 183 | test('should return base name with index when first try collides', () => { |
| 184 | const existing = new Set(['Alice']); |
| 185 | const result = getUniqueName('Alice', name => existing.has(name)); |
| 186 | expect(result).toBe('Alice (1)'); |
| 187 | }); |
| 188 | |
| 189 | test('should increment index until unique', () => { |
| 190 | const existing = new Set(['Bob', 'Bob (1)', 'Bob (2)']); |
| 191 | const result = getUniqueName('Bob', name => existing.has(name)); |
| 192 | expect(result).toBe('Bob (3)'); |
| 193 | }); |
| 194 | |
| 195 | test('should return null when maxTries exceeded', () => { |
| 196 | const result = getUniqueName('X', () => true, { maxTries: 3 }); |
| 197 | expect(result).toBeNull(); |
| 198 | }); |
| 199 | |
| 200 | test('should support custom nameBuilder', () => { |
| 201 | const existing = new Set(['doc.txt']); |
| 202 | const result = getUniqueName('doc.txt', name => existing.has(name), { |
| 203 | nameBuilder: (base, i) => `doc (${i}).txt`, |
| 204 | }); |
| 205 | expect(result).toBe('doc (1).txt'); |
| 206 | }); |
| 207 | |
| 208 | test('should check basename first when startIndex is 0', () => { |
| 209 | const result = getUniqueName('Free', () => false, { startIndex: 0 }); |
| 210 | expect(result).toBe('Free'); |
| 211 | }); |
| 212 | }); |
| 213 | |
| 214 | describe('removeFileExtension', () => { |
| 215 | test('should remove a single extension', () => { |
| 216 | expect(removeFileExtension('image.png')).toBe('image'); |
| 217 | }); |
| 218 | |
| 219 | test('should remove only the last extension', () => { |
| 220 | expect(removeFileExtension('archive.tar.gz')).toBe('archive.tar'); |
| 221 | }); |
| 222 | |
| 223 | test('should return filename unchanged if no extension', () => { |
| 224 | expect(removeFileExtension('README')).toBe('README'); |
| 225 | }); |
| 226 | |
| 227 | test('should handle dotfiles', () => { |
| 228 | expect(removeFileExtension('.gitignore')).toBe(''); |
| 229 | }); |
| 230 | }); |
| 231 | |
| 232 | describe('removeColorFormatting', () => { |
| 233 | test('should strip ANSI color codes', () => { |
| 234 | expect(removeColorFormatting('\x1b[31mError\x1b[0m')).toBe('Error'); |
| 235 | }); |
| 236 | |
| 237 | test('should return plain text unchanged', () => { |
| 238 | expect(removeColorFormatting('no colors here')).toBe('no colors here'); |
| 239 | }); |
| 240 | }); |
| 241 | |
| 242 | describe('getSeparator', () => { |
| 243 | test('should return n equals signs', () => { |
| 244 | expect(getSeparator(5)).toBe('====='); |
| 245 | }); |
| 246 | |
| 247 | test('should return empty string for 0', () => { |
| 248 | expect(getSeparator(0)).toBe(''); |
| 249 | }); |
| 250 | }); |
| 251 | |
| 252 | describe('isValidUrl', () => { |
| 253 | test('should accept valid HTTP URLs', () => { |
| 254 | expect(isValidUrl('https://example.com')).toBe(true); |
| 255 | expect(isValidUrl('http://localhost:8080/path')).toBe(true); |
| 256 | }); |
| 257 | |
| 258 | test('should accept file URLs', () => { |
| 259 | expect(isValidUrl('file:///tmp/test.txt')).toBe(true); |
| 260 | }); |
| 261 | |
| 262 | test('should reject non-URL strings', () => { |
| 263 | expect(isValidUrl('not a url')).toBe(false); |
| 264 | expect(isValidUrl('')).toBe(false); |
| 265 | }); |
| 266 | }); |
| 267 | |
| 268 | describe('urlHostnameToIPv6', () => { |
| 269 | test('should strip surrounding brackets', () => { |
| 270 | expect(urlHostnameToIPv6('[::1]')).toBe('::1'); |
| 271 | }); |
| 272 | |
| 273 | test('should handle already-clean hostname', () => { |
| 274 | expect(urlHostnameToIPv6('::1')).toBe('::1'); |
| 275 | }); |
| 276 | |
| 277 | test('should handle IPv4 passthrough', () => { |
| 278 | expect(urlHostnameToIPv6('127.0.0.1')).toBe('127.0.0.1'); |
| 279 | }); |
| 280 | }); |
| 281 | |
| 282 | describe('toBoolean', () => { |
| 283 | test('should handle "true" and "false" strings case-insensitively', () => { |
| 284 | expect(toBoolean('true')).toBe(true); |
| 285 | expect(toBoolean('TRUE')).toBe(true); |
| 286 | expect(toBoolean('false')).toBe(false); |
| 287 | expect(toBoolean('False')).toBe(false); |
| 288 | }); |
| 289 | |
| 290 | test('should handle whitespace around boolean strings', () => { |
| 291 | expect(toBoolean(' true ')).toBe(true); |
| 292 | }); |
| 293 | |
| 294 | test('should use JS truthiness for non-boolean strings', () => { |
| 295 | expect(toBoolean('hello')).toBe(true); |
| 296 | expect(toBoolean('')).toBe(false); |
| 297 | }); |
| 298 | |
| 299 | test('should handle non-string values', () => { |
| 300 | expect(toBoolean(1)).toBe(true); |
| 301 | expect(toBoolean(0)).toBe(false); |
| 302 | expect(toBoolean(null)).toBe(false); |
| 303 | expect(toBoolean(undefined)).toBe(false); |
| 304 | }); |
| 305 | }); |
| 306 | |
| 307 | describe('stringToBool', () => { |
| 308 | test('should convert "true" to true', () => { |
| 309 | expect(stringToBool('true')).toBe(true); |
| 310 | expect(stringToBool(' TRUE ')).toBe(true); |
| 311 | }); |
| 312 | |
| 313 | test('should convert "false" to false', () => { |
| 314 | expect(stringToBool('false')).toBe(false); |
| 315 | }); |
| 316 | |
| 317 | test('should pass through non-boolean strings', () => { |
| 318 | expect(stringToBool('hello')).toBe('hello'); |
| 319 | }); |
| 320 | |
| 321 | test('should pass through null', () => { |
| 322 | expect(stringToBool(null)).toBe(null); |
| 323 | }); |
| 324 | }); |
| 325 | |
| 326 | describe('trimV1', () => { |
| 327 | test('should remove trailing /v1', () => { |
| 328 | expect(trimV1('https://api.example.com/v1')).toBe('https://api.example.com'); |
| 329 | }); |
| 330 | |
| 331 | test('should remove trailing slash', () => { |
| 332 | expect(trimV1('https://api.example.com/')).toBe('https://api.example.com'); |
| 333 | }); |
| 334 | |
| 335 | test('should remove trailing slash then /v1', () => { |
| 336 | expect(trimV1('https://api.example.com/v1/')).toBe('https://api.example.com'); |
| 337 | }); |
| 338 | |
| 339 | test('should handle null/undefined gracefully', () => { |
| 340 | expect(trimV1(null)).toBe(''); |
| 341 | expect(trimV1(undefined)).toBe(''); |
| 342 | }); |
| 343 | }); |
| 344 | |
| 345 | describe('trimTrailingSlash', () => { |
| 346 | test('should remove trailing slash', () => { |
| 347 | expect(trimTrailingSlash('https://example.com/')).toBe('https://example.com'); |
| 348 | }); |
| 349 | |
| 350 | test('should leave non-trailing-slash URLs unchanged', () => { |
| 351 | expect(trimTrailingSlash('https://example.com')).toBe('https://example.com'); |
| 352 | }); |
| 353 | |
| 354 | test('should handle null/undefined gracefully', () => { |
| 355 | expect(trimTrailingSlash(null)).toBe(''); |
| 356 | }); |
| 357 | }); |
| 358 | |
| 359 | describe('mutateJsonString', () => { |
| 360 | test('should apply mutation and re-serialize', () => { |
| 361 | const result = mutateJsonString('{"a":1}', obj => { obj.b = 2; }); |
| 362 | expect(JSON.parse(result)).toEqual({ a: 1, b: 2 }); |
| 363 | }); |
| 364 | |
| 365 | test('should return original string on invalid JSON', () => { |
| 366 | const input = 'not json'; |
| 367 | const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); |
| 368 | expect(mutateJsonString(input, () => {})).toBe(input); |
| 369 | spy.mockRestore(); |
| 370 | }); |
| 371 | }); |
| 372 | |
| 373 | describe('isPathUnderParent', () => { |
| 374 | test('should accept child paths', () => { |
| 375 | expect(isPathUnderParent('/data', '/data/users/file.txt')).toBe(true); |
| 376 | }); |
| 377 | |
| 378 | test('should reject traversal attempts', () => { |
| 379 | expect(isPathUnderParent('/data', '/data/../etc/passwd')).toBe(false); |
| 380 | }); |
| 381 | |
| 382 | test('should reject sibling paths', () => { |
| 383 | expect(isPathUnderParent('/data/a', '/data/b')).toBe(false); |
| 384 | }); |
| 385 | |
| 386 | test('should accept the parent path itself', () => { |
| 387 | expect(isPathUnderParent('/data', '/data')).toBe(true); |
| 388 | }); |
| 389 | }); |
| 390 | |
| 391 | describe('isFileURL', () => { |
| 392 | test('should detect file:// string URLs', () => { |
| 393 | expect(isFileURL('file:///tmp/test.txt')).toBe(true); |
| 394 | }); |
| 395 | |
| 396 | test('should reject non-file string URLs', () => { |
| 397 | expect(isFileURL('https://example.com')).toBe(false); |
| 398 | }); |
| 399 | |
| 400 | test('should detect file:// URL objects', () => { |
| 401 | expect(isFileURL(new URL('file:///tmp/test.txt'))).toBe(true); |
| 402 | }); |
| 403 | |
| 404 | test('should detect file:// Request objects', () => { |
| 405 | expect(isFileURL(new Request('file:///tmp/test.txt'))).toBe(true); |
| 406 | }); |
| 407 | |
| 408 | test('should return false for non-matching types', () => { |
| 409 | expect(isFileURL(42)).toBe(false); |
| 410 | }); |
| 411 | }); |
| 412 | |
| 413 | describe('getRequestURL', () => { |
| 414 | test('should return string URLs as-is', () => { |
| 415 | expect(getRequestURL('https://example.com')).toBe('https://example.com'); |
| 416 | }); |
| 417 | |
| 418 | test('should extract href from URL objects', () => { |
| 419 | expect(getRequestURL(new URL('https://example.com/path'))).toBe('https://example.com/path'); |
| 420 | }); |
| 421 | |
| 422 | test('should extract url from Request objects', () => { |
| 423 | expect(getRequestURL(new Request('https://example.com/path'))).toBe('https://example.com/path'); |
| 424 | }); |
| 425 | |
| 426 | test('should throw for invalid types', () => { |
| 427 | expect(() => getRequestURL(42)).toThrow(TypeError); |
| 428 | }); |
| 429 | }); |
| 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 | }); |