/ tests / web-tools.test.ts
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  })