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 @@
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+} from '../src/util';
27+
28+describe('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+
42+describe('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+
52+describe('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+
67+describe('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+
95+describe('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+
123+describe('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+
136+describe('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+
146+describe('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+
164+describe('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+
174+describe('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+
206+describe('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+
224+describe('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+
234+describe('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+
244+describe('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+
260+describe('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+
274+describe('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+
299+describe('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+
318+describe('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+
337+describe('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+
351+describe('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+
365+describe('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+
383+describe('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+
405+describe('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+});