diff --git a/apps/hosting-service/src/lib/cache-manager.test.ts b/apps/hosting-service/src/lib/cache-manager.test.ts new file mode 100644 index 0000000..18e45c3 --- /dev/null +++ b/apps/hosting-service/src/lib/cache-manager.test.ts @@ -0,0 +1,254 @@ +import { describe, test, expect } from 'bun:test' +import { CacheManager } from './cache-manager' + +type TestNS = 'ttl' | 'lru' | 'sized' | 'combo' + +function createTestCache() { + return new CacheManager({ + ttl: { ttl: 100, maxEntries: 100 }, + lru: { maxEntries: 3 }, + sized: { maxEntries: 100, maxSize: 300, estimateSize: (v) => (v as string).length }, + combo: { ttl: 100, maxEntries: 3, maxSize: 500, estimateSize: (v) => (v as string).length }, + }) +} + +describe('CacheManager', () => { + describe('get / set basics', () => { + test('returns undefined for missing key', () => { + const c = createTestCache() + expect(c.get('ttl', 'missing')).toBeUndefined() + }) + + test('stores and retrieves a value', () => { + const c = createTestCache() + c.set('ttl', 'k', 42) + expect(c.get('ttl', 'k')).toBe(42) + }) + + test('namespaces are isolated', () => { + const c = createTestCache() + c.set('ttl', 'k', 'from-ttl') + c.set('lru', 'k', 'from-lru') + expect(c.get('ttl', 'k')).toBe('from-ttl') + expect(c.get('lru', 'k')).toBe('from-lru') + }) + + test('overwrites existing key', () => { + const c = createTestCache() + c.set('lru', 'k', 'v1') + c.set('lru', 'k', 'v2') + expect(c.get('lru', 'k')).toBe('v2') + }) + }) + + describe('TTL expiry', () => { + test('returns value within TTL', () => { + const c = createTestCache() + c.set('ttl', 'k', 'fresh') + expect(c.get('ttl', 'k')).toBe('fresh') + }) + + test('expires value after TTL', async () => { + const c = createTestCache() + c.set('ttl', 'k', 'stale') + await Bun.sleep(150) + expect(c.get('ttl', 'k')).toBeUndefined() + }) + }) + + describe('LRU eviction by maxEntries', () => { + test('evicts oldest entry when maxEntries exceeded', () => { + const c = createTestCache() + c.set('lru', 'a', 1) + c.set('lru', 'b', 2) + c.set('lru', 'c', 3) + // At capacity (3). Adding a 4th should evict 'a' (oldest). + c.set('lru', 'd', 4) + expect(c.get('lru', 'a')).toBeUndefined() + expect(c.get('lru', 'b')).toBe(2) + expect(c.get('lru', 'd')).toBe(4) + }) + + test('accessing a key refreshes its LRU position', () => { + const c = createTestCache() + c.set('lru', 'a', 1) + c.set('lru', 'b', 2) + c.set('lru', 'c', 3) + // Touch 'a' so it's no longer the oldest + c.get('lru', 'a') + // Now 'b' is the oldest + c.set('lru', 'd', 4) + expect(c.get('lru', 'b')).toBeUndefined() + expect(c.get('lru', 'a')).toBe(1) + }) + }) + + describe('LRU eviction by maxSize', () => { + test('evicts entries when maxSize exceeded', () => { + const c = createTestCache() + // sized ns: maxSize=300, estimateSize = string length + c.set('sized', 'a', 'x'.repeat(100)) + c.set('sized', 'b', 'x'.repeat(100)) + c.set('sized', 'c', 'x'.repeat(100)) + // At 300 bytes. Adding 150 more should evict until it fits. + c.set('sized', 'd', 'x'.repeat(150)) + expect(c.get('sized', 'a')).toBeUndefined() + expect(c.get('sized', 'd')).toBeDefined() + }) + }) + + describe('delete', () => { + test('removes a key', () => { + const c = createTestCache() + c.set('lru', 'k', 'val') + c.delete('lru', 'k') + expect(c.get('lru', 'k')).toBeUndefined() + }) + + test('delete on missing key is a no-op', () => { + const c = createTestCache() + c.delete('lru', 'nonexistent') // should not throw + }) + }) + + describe('clear', () => { + test('removes all entries in a namespace', () => { + const c = createTestCache() + c.set('lru', 'a', 1) + c.set('lru', 'b', 2) + c.set('ttl', 'x', 3) + c.clear('lru') + expect(c.get('lru', 'a')).toBeUndefined() + expect(c.get('lru', 'b')).toBeUndefined() + // Other namespace untouched + expect(c.get('ttl', 'x')).toBe(3) + }) + }) + + describe('getOrFetch', () => { + test('calls fetcher on cache miss', async () => { + const c = createTestCache() + let called = 0 + const val = await c.getOrFetch('lru', 'k', () => { called++; return 'fetched' }) + expect(val).toBe('fetched') + expect(called).toBe(1) + }) + + test('returns cached value without calling fetcher', async () => { + const c = createTestCache() + c.set('lru', 'k', 'cached') + let called = 0 + const val = await c.getOrFetch('lru', 'k', () => { called++; return 'fetched' }) + expect(val).toBe('cached') + expect(called).toBe(0) + }) + + test('works with async fetcher', async () => { + const c = createTestCache() + const val = await c.getOrFetch('lru', 'k', async () => { + await Bun.sleep(5) + return 'async-result' + }) + expect(val).toBe('async-result') + // Second call should be from cache + expect(c.get('lru', 'k')).toBe('async-result') + }) + + test('cacheIf: false skips caching', async () => { + const c = createTestCache() + const val = await c.getOrFetch('lru', 'k', () => null, { + cacheIf: (v) => v !== null, + }) + expect(val).toBeNull() + // Should NOT be cached + expect(c.get('lru', 'k')).toBeUndefined() + }) + + test('cacheIf: true caches normally', async () => { + const c = createTestCache() + const val = await c.getOrFetch('lru', 'k', () => 'good', { + cacheIf: (v) => v !== null, + }) + expect(val).toBe('good') + expect(c.get('lru', 'k')).toBe('good') + }) + }) + + describe('stats', () => { + test('tracks hits and misses', () => { + const c = createTestCache() + c.get('lru', 'miss1') + c.get('lru', 'miss2') + c.set('lru', 'k', 'v') + c.get('lru', 'k') + const stats = c.getStats() + expect(stats.lru.misses).toBe(2) + expect(stats.lru.hits).toBe(1) + }) + + test('tracks evictions', () => { + const c = createTestCache() + c.set('lru', 'a', 1) + c.set('lru', 'b', 2) + c.set('lru', 'c', 3) + c.set('lru', 'd', 4) // evicts 'a' + const stats = c.getStats() + expect(stats.lru.evictions).toBe(1) + }) + + test('tracks entries count', () => { + const c = createTestCache() + c.set('lru', 'a', 1) + c.set('lru', 'b', 2) + expect(c.getStats().lru.entries).toBe(2) + c.delete('lru', 'a') + expect(c.getStats().lru.entries).toBe(1) + }) + + test('tracks sizeBytes with estimateSize', () => { + const c = createTestCache() + c.set('sized', 'a', 'hello') // 5 bytes + c.set('sized', 'b', 'world!') // 6 bytes + expect(c.getStats().sized.sizeBytes).toBe(11) + c.delete('sized', 'a') + expect(c.getStats().sized.sizeBytes).toBe(6) + }) + + test('returns independent stats per namespace', () => { + const c = createTestCache() + c.set('ttl', 'a', 1) + c.set('lru', 'b', 2) + const stats = c.getStats() + expect(stats.ttl.entries).toBe(1) + expect(stats.lru.entries).toBe(1) + expect(stats.sized.entries).toBe(0) + }) + }) + + describe('cleanup', () => { + test('startCleanup / stopCleanup do not throw', () => { + const c = createTestCache() + c.startCleanup(50) + c.stopCleanup() + }) + + test('cleanup sweeps expired TTL entries', async () => { + const c = createTestCache() + c.set('ttl', 'k', 'val') + c.startCleanup(50) + // Wait for TTL (100ms) + cleanup interval (50ms) + buffer + await Bun.sleep(200) + c.stopCleanup() + expect(c.get('ttl', 'k')).toBeUndefined() + }) + + test('cleanup does not touch non-TTL namespaces', async () => { + const c = createTestCache() + c.set('lru', 'k', 'val') + c.startCleanup(50) + await Bun.sleep(200) + c.stopCleanup() + expect(c.get('lru', 'k')).toBe('val') + }) + }) +}) diff --git a/apps/hosting-service/src/lib/file-serving.test.ts b/apps/hosting-service/src/lib/file-serving.test.ts new file mode 100644 index 0000000..c55d3d4 --- /dev/null +++ b/apps/hosting-service/src/lib/file-serving.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from 'bun:test' +import { hasFileExtension } from './file-serving' + +describe('hasFileExtension', () => { + describe('paths with extensions', () => { + test('simple file extension', () => { + expect(hasFileExtension('style.css')).toBe(true) + }) + + test('html extension', () => { + expect(hasFileExtension('index.html')).toBe(true) + }) + + test('nested path with extension', () => { + expect(hasFileExtension('assets/js/app.js')).toBe(true) + }) + + test('double extension', () => { + expect(hasFileExtension('archive.tar.gz')).toBe(true) + }) + + test('dotfile with extension', () => { + expect(hasFileExtension('.htaccess')).toBe(true) + }) + + test('minified file', () => { + expect(hasFileExtension('bundle.min.js')).toBe(true) + }) + + test('image file', () => { + expect(hasFileExtension('photo.png')).toBe(true) + }) + + test('sourcemap', () => { + expect(hasFileExtension('app.js.map')).toBe(true) + }) + }) + + describe('paths without extensions (extensionless files)', () => { + test('simple name', () => { + expect(hasFileExtension('about')).toBe(false) + }) + + test('binary-style name with dashes', () => { + expect(hasFileExtension('wisp-cli-x86_64-linux')).toBe(false) + }) + + test('binary-style name with underscores', () => { + expect(hasFileExtension('my_binary_v2')).toBe(false) + }) + + test('empty string', () => { + expect(hasFileExtension('')).toBe(false) + }) + + test('single word', () => { + expect(hasFileExtension('README')).toBe(false) + }) + + test('path with trailing slash', () => { + expect(hasFileExtension('somedir/')).toBe(false) + }) + }) + + describe('directory-with-dot edge cases', () => { + test('dot in directory name, extensionless file', () => { + expect(hasFileExtension('my.folder/file')).toBe(false) + }) + + test('dot in directory name, file with extension', () => { + expect(hasFileExtension('my.folder/index.html')).toBe(true) + }) + + test('multiple dotted directories, extensionless file', () => { + expect(hasFileExtension('v1.0/api.v2/handler')).toBe(false) + }) + + test('multiple dotted directories, file with extension', () => { + expect(hasFileExtension('v1.0/api.v2/handler.js')).toBe(true) + }) + }) + + describe('trailing-dot edge case', () => { + test('trailing dot is not a file extension', () => { + // "file." has a dot but no alphanumeric chars after it + expect(hasFileExtension('file.')).toBe(false) + }) + }) +}) diff --git a/apps/hosting-service/src/lib/file-serving.ts b/apps/hosting-service/src/lib/file-serving.ts index 8623c2b..6a451fc 100644 --- a/apps/hosting-service/src/lib/file-serving.ts +++ b/apps/hosting-service/src/lib/file-serving.ts @@ -32,7 +32,7 @@ type FileStorageResult = StorageResult; * e.g. "style.css" → true, "about" → false, "wisp-cli-x86_64-linux" → false, * "dir.name/file" → false, "dir/file.tar.gz" → true */ -function hasFileExtension(path: string): boolean { +export function hasFileExtension(path: string): boolean { const basename = path.split('/').pop() || ''; return /\.[a-zA-Z0-9]+$/.test(basename); }