web-tools.test.ts
1 import { describe, expect, it, beforeEach } from 'vitest' 2 3 import { 4 normalizeCacheKey, 5 readCache, 6 readResponseText, 7 resolveCacheTtlMs, 8 resolveTimeoutSeconds, 9 withTimeout, 10 writeCache, 11 } from '@/lib/web-core' 12 import type { CacheEntry } from '@/lib/web-core' 13 14 describe('resolveTimeoutSeconds', () => { 15 it('returns the value if it is a valid number', () => { 16 expect(resolveTimeoutSeconds(10, 30)).toBe(10) 17 expect(resolveTimeoutSeconds(1, 30)).toBe(1) 18 }) 19 20 it('returns the fallback for invalid values', () => { 21 expect(resolveTimeoutSeconds(undefined, 30)).toBe(30) 22 expect(resolveTimeoutSeconds(null, 30)).toBe(30) 23 expect(resolveTimeoutSeconds('10', 30)).toBe(30) 24 expect(resolveTimeoutSeconds(NaN, 30)).toBe(30) 25 }) 26 27 it('clamps to minimum of 1', () => { 28 expect(resolveTimeoutSeconds(0, 30)).toBe(1) 29 expect(resolveTimeoutSeconds(-5, 30)).toBe(1) 30 }) 31 32 it('floors the value', () => { 33 expect(resolveTimeoutSeconds(10.9, 30)).toBe(10) 34 expect(resolveTimeoutSeconds(10.1, 30)).toBe(10) 35 }) 36 }) 37 38 describe('resolveCacheTtlMs', () => { 39 it('converts minutes to milliseconds', () => { 40 expect(resolveCacheTtlMs(15, 30)).toBe(15 * 60_000) 41 expect(resolveCacheTtlMs(1, 30)).toBe(60_000) 42 }) 43 44 it('returns fallback for invalid values', () => { 45 expect(resolveCacheTtlMs(undefined, 30)).toBe(30 * 60_000) 46 expect(resolveCacheTtlMs(null, 30)).toBe(30 * 60_000) 47 expect(resolveCacheTtlMs(NaN, 30)).toBe(30 * 60_000) 48 }) 49 50 it('clamps to minimum of 0', () => { 51 expect(resolveCacheTtlMs(-5, 30)).toBe(0) 52 }) 53 }) 54 55 describe('normalizeCacheKey', () => { 56 it('trims and lowercases the key', () => { 57 expect(normalizeCacheKey(' Hello World ')).toBe('hello world') 58 expect(normalizeCacheKey('TEST')).toBe('test') 59 }) 60 }) 61 62 describe('readCache', () => { 63 let cache: Map<string, CacheEntry<{ foo: string }>> 64 65 beforeEach(() => { 66 cache = new Map() 67 }) 68 69 it('returns null for missing keys', () => { 70 expect(readCache(cache, 'missing')).toBeNull() 71 }) 72 73 it('returns cached value if not expired', () => { 74 const entry: CacheEntry<{ foo: string }> = { 75 value: { foo: 'bar' }, 76 expiresAt: Date.now() + 60_000, 77 insertedAt: Date.now(), 78 } 79 cache.set('key', entry) 80 81 const result = readCache(cache, 'key') 82 expect(result).not.toBeNull() 83 expect(result?.value).toEqual({ foo: 'bar' }) 84 expect(result?.cached).toBe(true) 85 }) 86 87 it('returns null and deletes expired entries', () => { 88 const entry: CacheEntry<{ foo: string }> = { 89 value: { foo: 'bar' }, 90 expiresAt: Date.now() - 60_000, // expired 91 insertedAt: Date.now() - 120_000, 92 } 93 cache.set('key', entry) 94 95 const result = readCache(cache, 'key') 96 expect(result).toBeNull() 97 expect(cache.has('key')).toBe(false) 98 }) 99 }) 100 101 describe('writeCache', () => { 102 let cache: Map<string, CacheEntry<{ foo: string }>> 103 104 beforeEach(() => { 105 cache = new Map() 106 }) 107 108 it('writes a value to the cache', () => { 109 writeCache(cache, 'key', { foo: 'bar' }, 60_000) 110 111 expect(cache.has('key')).toBe(true) 112 const entry = cache.get('key') 113 expect(entry?.value).toEqual({ foo: 'bar' }) 114 expect(entry?.expiresAt).toBeGreaterThan(Date.now()) 115 }) 116 117 it('does not write if ttlMs is 0', () => { 118 writeCache(cache, 'key', { foo: 'bar' }, 0) 119 expect(cache.has('key')).toBe(false) 120 }) 121 122 it('evicts oldest entry when cache is full', () => { 123 // Fill cache to max (100 entries) 124 for (let i = 0; i < 100; i++) { 125 writeCache(cache, `key${i}`, { foo: `bar${i}` }, 60_000) 126 } 127 128 // Add one more to trigger eviction 129 writeCache(cache, 'newKey', { foo: 'new' }, 60_000) 130 131 expect(cache.has('newKey')).toBe(true) 132 expect(cache.size).toBeLessThanOrEqual(100) 133 }) 134 }) 135 136 describe('withTimeout', () => { 137 it('returns the original signal if timeout is <= 0', () => { 138 const controller = new AbortController() 139 const signal = withTimeout(controller.signal, 0) 140 141 expect(signal).toBe(controller.signal) 142 }) 143 144 it('creates a new signal that aborts after timeout', async () => { 145 const signal = withTimeout(undefined, 50) 146 147 await expect( 148 new Promise<void>((resolve, reject) => { 149 signal.addEventListener('abort', () => resolve()) 150 setTimeout(() => reject(new Error('Timed out')), 100) 151 }), 152 ).resolves.toBeUndefined() 153 }) 154 155 it('aborts when original signal is aborted after timeout starts', async () => { 156 const controller = new AbortController() 157 const signal = withTimeout(controller.signal, 10000) 158 159 // Wait a bit then abort 160 setTimeout(() => controller.abort(), 10) 161 162 await expect( 163 new Promise<void>((resolve) => { 164 signal.addEventListener('abort', () => resolve()) 165 }), 166 ).resolves.toBeUndefined() 167 }) 168 }) 169 170 describe('readResponseText', () => { 171 it('reads text from a basic Response', async () => { 172 const response = new Response('Hello World', { 173 headers: { 'content-type': 'text/plain' }, 174 }) 175 176 const result = await readResponseText(response) 177 178 expect(result.text).toBe('Hello World') 179 expect(result.truncated).toBe(false) 180 expect(result.bytesRead).toBe(11) 181 }) 182 183 it('respects maxBytes limit', async () => { 184 const longText = 'a'.repeat(1000) 185 const response = new Response(longText) 186 187 const result = await readResponseText(response, { maxBytes: 100 }) 188 189 expect(result.text.length).toBe(100) 190 expect(result.truncated).toBe(true) 191 expect(result.bytesRead).toBe(100) 192 }) 193 194 it('handles empty body', async () => { 195 const response = new Response(null) 196 197 const result = await readResponseText(response) 198 199 expect(result.text).toBe('') 200 expect(result.truncated).toBe(false) 201 expect(result.bytesRead).toBe(0) 202 }) 203 })