Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

final pass

+344 -1
+254
apps/hosting-service/src/lib/cache-manager.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { CacheManager } from './cache-manager' 3 + 4 + type TestNS = 'ttl' | 'lru' | 'sized' | 'combo' 5 + 6 + function createTestCache() { 7 + return new CacheManager<TestNS>({ 8 + ttl: { ttl: 100, maxEntries: 100 }, 9 + lru: { maxEntries: 3 }, 10 + sized: { maxEntries: 100, maxSize: 300, estimateSize: (v) => (v as string).length }, 11 + combo: { ttl: 100, maxEntries: 3, maxSize: 500, estimateSize: (v) => (v as string).length }, 12 + }) 13 + } 14 + 15 + describe('CacheManager', () => { 16 + describe('get / set basics', () => { 17 + test('returns undefined for missing key', () => { 18 + const c = createTestCache() 19 + expect(c.get('ttl', 'missing')).toBeUndefined() 20 + }) 21 + 22 + test('stores and retrieves a value', () => { 23 + const c = createTestCache() 24 + c.set('ttl', 'k', 42) 25 + expect(c.get('ttl', 'k')).toBe(42) 26 + }) 27 + 28 + test('namespaces are isolated', () => { 29 + const c = createTestCache() 30 + c.set('ttl', 'k', 'from-ttl') 31 + c.set('lru', 'k', 'from-lru') 32 + expect(c.get('ttl', 'k')).toBe('from-ttl') 33 + expect(c.get('lru', 'k')).toBe('from-lru') 34 + }) 35 + 36 + test('overwrites existing key', () => { 37 + const c = createTestCache() 38 + c.set('lru', 'k', 'v1') 39 + c.set('lru', 'k', 'v2') 40 + expect(c.get('lru', 'k')).toBe('v2') 41 + }) 42 + }) 43 + 44 + describe('TTL expiry', () => { 45 + test('returns value within TTL', () => { 46 + const c = createTestCache() 47 + c.set('ttl', 'k', 'fresh') 48 + expect(c.get('ttl', 'k')).toBe('fresh') 49 + }) 50 + 51 + test('expires value after TTL', async () => { 52 + const c = createTestCache() 53 + c.set('ttl', 'k', 'stale') 54 + await Bun.sleep(150) 55 + expect(c.get('ttl', 'k')).toBeUndefined() 56 + }) 57 + }) 58 + 59 + describe('LRU eviction by maxEntries', () => { 60 + test('evicts oldest entry when maxEntries exceeded', () => { 61 + const c = createTestCache() 62 + c.set('lru', 'a', 1) 63 + c.set('lru', 'b', 2) 64 + c.set('lru', 'c', 3) 65 + // At capacity (3). Adding a 4th should evict 'a' (oldest). 66 + c.set('lru', 'd', 4) 67 + expect(c.get('lru', 'a')).toBeUndefined() 68 + expect(c.get('lru', 'b')).toBe(2) 69 + expect(c.get('lru', 'd')).toBe(4) 70 + }) 71 + 72 + test('accessing a key refreshes its LRU position', () => { 73 + const c = createTestCache() 74 + c.set('lru', 'a', 1) 75 + c.set('lru', 'b', 2) 76 + c.set('lru', 'c', 3) 77 + // Touch 'a' so it's no longer the oldest 78 + c.get('lru', 'a') 79 + // Now 'b' is the oldest 80 + c.set('lru', 'd', 4) 81 + expect(c.get('lru', 'b')).toBeUndefined() 82 + expect(c.get('lru', 'a')).toBe(1) 83 + }) 84 + }) 85 + 86 + describe('LRU eviction by maxSize', () => { 87 + test('evicts entries when maxSize exceeded', () => { 88 + const c = createTestCache() 89 + // sized ns: maxSize=300, estimateSize = string length 90 + c.set('sized', 'a', 'x'.repeat(100)) 91 + c.set('sized', 'b', 'x'.repeat(100)) 92 + c.set('sized', 'c', 'x'.repeat(100)) 93 + // At 300 bytes. Adding 150 more should evict until it fits. 94 + c.set('sized', 'd', 'x'.repeat(150)) 95 + expect(c.get('sized', 'a')).toBeUndefined() 96 + expect(c.get('sized', 'd')).toBeDefined() 97 + }) 98 + }) 99 + 100 + describe('delete', () => { 101 + test('removes a key', () => { 102 + const c = createTestCache() 103 + c.set('lru', 'k', 'val') 104 + c.delete('lru', 'k') 105 + expect(c.get('lru', 'k')).toBeUndefined() 106 + }) 107 + 108 + test('delete on missing key is a no-op', () => { 109 + const c = createTestCache() 110 + c.delete('lru', 'nonexistent') // should not throw 111 + }) 112 + }) 113 + 114 + describe('clear', () => { 115 + test('removes all entries in a namespace', () => { 116 + const c = createTestCache() 117 + c.set('lru', 'a', 1) 118 + c.set('lru', 'b', 2) 119 + c.set('ttl', 'x', 3) 120 + c.clear('lru') 121 + expect(c.get('lru', 'a')).toBeUndefined() 122 + expect(c.get('lru', 'b')).toBeUndefined() 123 + // Other namespace untouched 124 + expect(c.get('ttl', 'x')).toBe(3) 125 + }) 126 + }) 127 + 128 + describe('getOrFetch', () => { 129 + test('calls fetcher on cache miss', async () => { 130 + const c = createTestCache() 131 + let called = 0 132 + const val = await c.getOrFetch('lru', 'k', () => { called++; return 'fetched' }) 133 + expect(val).toBe('fetched') 134 + expect(called).toBe(1) 135 + }) 136 + 137 + test('returns cached value without calling fetcher', async () => { 138 + const c = createTestCache() 139 + c.set('lru', 'k', 'cached') 140 + let called = 0 141 + const val = await c.getOrFetch('lru', 'k', () => { called++; return 'fetched' }) 142 + expect(val).toBe('cached') 143 + expect(called).toBe(0) 144 + }) 145 + 146 + test('works with async fetcher', async () => { 147 + const c = createTestCache() 148 + const val = await c.getOrFetch('lru', 'k', async () => { 149 + await Bun.sleep(5) 150 + return 'async-result' 151 + }) 152 + expect(val).toBe('async-result') 153 + // Second call should be from cache 154 + expect(c.get('lru', 'k')).toBe('async-result') 155 + }) 156 + 157 + test('cacheIf: false skips caching', async () => { 158 + const c = createTestCache() 159 + const val = await c.getOrFetch('lru', 'k', () => null, { 160 + cacheIf: (v) => v !== null, 161 + }) 162 + expect(val).toBeNull() 163 + // Should NOT be cached 164 + expect(c.get('lru', 'k')).toBeUndefined() 165 + }) 166 + 167 + test('cacheIf: true caches normally', async () => { 168 + const c = createTestCache() 169 + const val = await c.getOrFetch('lru', 'k', () => 'good', { 170 + cacheIf: (v) => v !== null, 171 + }) 172 + expect(val).toBe('good') 173 + expect(c.get('lru', 'k')).toBe('good') 174 + }) 175 + }) 176 + 177 + describe('stats', () => { 178 + test('tracks hits and misses', () => { 179 + const c = createTestCache() 180 + c.get('lru', 'miss1') 181 + c.get('lru', 'miss2') 182 + c.set('lru', 'k', 'v') 183 + c.get('lru', 'k') 184 + const stats = c.getStats() 185 + expect(stats.lru.misses).toBe(2) 186 + expect(stats.lru.hits).toBe(1) 187 + }) 188 + 189 + test('tracks evictions', () => { 190 + const c = createTestCache() 191 + c.set('lru', 'a', 1) 192 + c.set('lru', 'b', 2) 193 + c.set('lru', 'c', 3) 194 + c.set('lru', 'd', 4) // evicts 'a' 195 + const stats = c.getStats() 196 + expect(stats.lru.evictions).toBe(1) 197 + }) 198 + 199 + test('tracks entries count', () => { 200 + const c = createTestCache() 201 + c.set('lru', 'a', 1) 202 + c.set('lru', 'b', 2) 203 + expect(c.getStats().lru.entries).toBe(2) 204 + c.delete('lru', 'a') 205 + expect(c.getStats().lru.entries).toBe(1) 206 + }) 207 + 208 + test('tracks sizeBytes with estimateSize', () => { 209 + const c = createTestCache() 210 + c.set('sized', 'a', 'hello') // 5 bytes 211 + c.set('sized', 'b', 'world!') // 6 bytes 212 + expect(c.getStats().sized.sizeBytes).toBe(11) 213 + c.delete('sized', 'a') 214 + expect(c.getStats().sized.sizeBytes).toBe(6) 215 + }) 216 + 217 + test('returns independent stats per namespace', () => { 218 + const c = createTestCache() 219 + c.set('ttl', 'a', 1) 220 + c.set('lru', 'b', 2) 221 + const stats = c.getStats() 222 + expect(stats.ttl.entries).toBe(1) 223 + expect(stats.lru.entries).toBe(1) 224 + expect(stats.sized.entries).toBe(0) 225 + }) 226 + }) 227 + 228 + describe('cleanup', () => { 229 + test('startCleanup / stopCleanup do not throw', () => { 230 + const c = createTestCache() 231 + c.startCleanup(50) 232 + c.stopCleanup() 233 + }) 234 + 235 + test('cleanup sweeps expired TTL entries', async () => { 236 + const c = createTestCache() 237 + c.set('ttl', 'k', 'val') 238 + c.startCleanup(50) 239 + // Wait for TTL (100ms) + cleanup interval (50ms) + buffer 240 + await Bun.sleep(200) 241 + c.stopCleanup() 242 + expect(c.get('ttl', 'k')).toBeUndefined() 243 + }) 244 + 245 + test('cleanup does not touch non-TTL namespaces', async () => { 246 + const c = createTestCache() 247 + c.set('lru', 'k', 'val') 248 + c.startCleanup(50) 249 + await Bun.sleep(200) 250 + c.stopCleanup() 251 + expect(c.get('lru', 'k')).toBe('val') 252 + }) 253 + }) 254 + })
+89
apps/hosting-service/src/lib/file-serving.test.ts
··· 1 + import { describe, test, expect } from 'bun:test' 2 + import { hasFileExtension } from './file-serving' 3 + 4 + describe('hasFileExtension', () => { 5 + describe('paths with extensions', () => { 6 + test('simple file extension', () => { 7 + expect(hasFileExtension('style.css')).toBe(true) 8 + }) 9 + 10 + test('html extension', () => { 11 + expect(hasFileExtension('index.html')).toBe(true) 12 + }) 13 + 14 + test('nested path with extension', () => { 15 + expect(hasFileExtension('assets/js/app.js')).toBe(true) 16 + }) 17 + 18 + test('double extension', () => { 19 + expect(hasFileExtension('archive.tar.gz')).toBe(true) 20 + }) 21 + 22 + test('dotfile with extension', () => { 23 + expect(hasFileExtension('.htaccess')).toBe(true) 24 + }) 25 + 26 + test('minified file', () => { 27 + expect(hasFileExtension('bundle.min.js')).toBe(true) 28 + }) 29 + 30 + test('image file', () => { 31 + expect(hasFileExtension('photo.png')).toBe(true) 32 + }) 33 + 34 + test('sourcemap', () => { 35 + expect(hasFileExtension('app.js.map')).toBe(true) 36 + }) 37 + }) 38 + 39 + describe('paths without extensions (extensionless files)', () => { 40 + test('simple name', () => { 41 + expect(hasFileExtension('about')).toBe(false) 42 + }) 43 + 44 + test('binary-style name with dashes', () => { 45 + expect(hasFileExtension('wisp-cli-x86_64-linux')).toBe(false) 46 + }) 47 + 48 + test('binary-style name with underscores', () => { 49 + expect(hasFileExtension('my_binary_v2')).toBe(false) 50 + }) 51 + 52 + test('empty string', () => { 53 + expect(hasFileExtension('')).toBe(false) 54 + }) 55 + 56 + test('single word', () => { 57 + expect(hasFileExtension('README')).toBe(false) 58 + }) 59 + 60 + test('path with trailing slash', () => { 61 + expect(hasFileExtension('somedir/')).toBe(false) 62 + }) 63 + }) 64 + 65 + describe('directory-with-dot edge cases', () => { 66 + test('dot in directory name, extensionless file', () => { 67 + expect(hasFileExtension('my.folder/file')).toBe(false) 68 + }) 69 + 70 + test('dot in directory name, file with extension', () => { 71 + expect(hasFileExtension('my.folder/index.html')).toBe(true) 72 + }) 73 + 74 + test('multiple dotted directories, extensionless file', () => { 75 + expect(hasFileExtension('v1.0/api.v2/handler')).toBe(false) 76 + }) 77 + 78 + test('multiple dotted directories, file with extension', () => { 79 + expect(hasFileExtension('v1.0/api.v2/handler.js')).toBe(true) 80 + }) 81 + }) 82 + 83 + describe('trailing-dot edge case', () => { 84 + test('trailing dot is not a file extension', () => { 85 + // "file." has a dot but no alphanumeric chars after it 86 + expect(hasFileExtension('file.')).toBe(false) 87 + }) 88 + }) 89 + })
+1 -1
apps/hosting-service/src/lib/file-serving.ts
··· 32 32 * e.g. "style.css" → true, "about" → false, "wisp-cli-x86_64-linux" → false, 33 33 * "dir.name/file" → false, "dir/file.tar.gz" → true 34 34 */ 35 - function hasFileExtension(path: string): boolean { 35 + export function hasFileExtension(path: string): boolean { 36 36 const basename = path.split('/').pop() || ''; 37 37 return /\.[a-zA-Z0-9]+$/.test(basename); 38 38 }