Add unit tests for pure functions in src/util.js (#5231) 77 tests covering 24 pure functions: keyToEnv, getBasicAuthHeader, getHexString, normalizeZipEntryPath, deepMerge, uuidv4, humanizedDateTime, tryParse, clientRelativePath, getUniqueName, removeFileExtension, removeColorFormatting, getSeparator, isValidUrl, urlHostnameToIPv6, toBoolean, stringToBool, trimV1, trimTrailingSlash, mutateJsonString, isPathUnderParent, isFileURL, getRequestURL, and formatBytes (via getBasicAuthHeader pattern). These are all deterministic, side-effect-free functions tested without any mocking. Placed in a separate file from the existing util.test.js to keep concerns separated.

994234e5576712337bac6fdb04a2dd8745926276

Tony Gies <tgies@tgies.net>

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