|
| 1 | +import { describe, it, before, beforeEach, afterEach, after } from 'node:test'; |
| 2 | +import assert from 'node:assert/strict'; |
| 3 | + |
| 4 | +// HttpCache is exported as a singleton, so we import the module and access |
| 5 | +// the class constructor through it for static method testing. |
| 6 | +import cache from '../../proxy/cache.mjs'; |
| 7 | + |
| 8 | +const HttpCache = cache.constructor; |
| 9 | + |
| 10 | +// Tear down the singleton's cleanup interval so Node can exit cleanly |
| 11 | +after(() => { |
| 12 | + cache.destroy(); |
| 13 | +}); |
| 14 | + |
| 15 | +describe('HttpCache.parseCacheControl', () => { |
| 16 | + it('returns 0 for null/undefined input', () => { |
| 17 | + assert.equal(HttpCache.parseCacheControl(null), 0); |
| 18 | + assert.equal(HttpCache.parseCacheControl(undefined), 0); |
| 19 | + assert.equal(HttpCache.parseCacheControl(''), 0); |
| 20 | + }); |
| 21 | + |
| 22 | + it('extracts max-age value', () => { |
| 23 | + assert.equal(HttpCache.parseCacheControl('max-age=300'), 300); |
| 24 | + assert.equal(HttpCache.parseCacheControl('public, max-age=600'), 600); |
| 25 | + }); |
| 26 | + |
| 27 | + it('prefers s-maxage over max-age', () => { |
| 28 | + assert.equal(HttpCache.parseCacheControl('s-maxage=120, max-age=600'), 120); |
| 29 | + }); |
| 30 | + |
| 31 | + it('extracts s-maxage when present alone', () => { |
| 32 | + assert.equal(HttpCache.parseCacheControl('s-maxage=3600'), 3600); |
| 33 | + }); |
| 34 | + |
| 35 | + it('returns 0 for no-cache directives', () => { |
| 36 | + assert.equal(HttpCache.parseCacheControl('no-cache'), 0); |
| 37 | + assert.equal(HttpCache.parseCacheControl('no-store'), 0); |
| 38 | + }); |
| 39 | + |
| 40 | + it('is case-insensitive', () => { |
| 41 | + assert.equal(HttpCache.parseCacheControl('Max-Age=120'), 120); |
| 42 | + assert.equal(HttpCache.parseCacheControl('S-MAXAGE=60'), 60); |
| 43 | + }); |
| 44 | +}); |
| 45 | + |
| 46 | +describe('HttpCache.generateKey', () => { |
| 47 | + it('generates key from path when no query string', () => { |
| 48 | + const req = { path: '/api/points/42,-90', url: '/api/points/42,-90' }; |
| 49 | + const key = HttpCache.generateKey(req); |
| 50 | + assert.equal(key, '/api/points/42,-90'); |
| 51 | + }); |
| 52 | + |
| 53 | + it('includes query string in key', () => { |
| 54 | + const req = { path: '/api/points/42,-90', url: '/api/points/42,-90?units=us' }; |
| 55 | + const key = HttpCache.generateKey(req); |
| 56 | + assert.equal(key, '/api/points/42,-90?units=us'); |
| 57 | + }); |
| 58 | + |
| 59 | + it('uses path as fallback when url is missing', () => { |
| 60 | + const req = { path: '/api/forecast' }; |
| 61 | + const key = HttpCache.generateKey(req); |
| 62 | + assert.equal(key, '/api/forecast'); |
| 63 | + }); |
| 64 | + |
| 65 | + it('uses url as fallback when path is missing', () => { |
| 66 | + // When path is missing, url is used for both path and url, |
| 67 | + // so query string gets appended again |
| 68 | + const req = { url: '/api/forecast?a=1' }; |
| 69 | + const key = HttpCache.generateKey(req); |
| 70 | + assert.equal(key, '/api/forecast?a=1?a=1'); |
| 71 | + }); |
| 72 | + |
| 73 | + it('defaults to / when both are missing', () => { |
| 74 | + const req = {}; |
| 75 | + const key = HttpCache.generateKey(req); |
| 76 | + assert.equal(key, '/'); |
| 77 | + }); |
| 78 | +}); |
| 79 | + |
| 80 | +describe('HttpCache.setFilteredHeaders', () => { |
| 81 | + it('strips cache-related headers and sets proxy cache policy', () => { |
| 82 | + const headersSet = {}; |
| 83 | + const res = { |
| 84 | + header(name, value) { headersSet[name] = value; }, |
| 85 | + }; |
| 86 | + |
| 87 | + HttpCache.setFilteredHeaders(res, { |
| 88 | + 'content-type': 'application/json', |
| 89 | + 'cache-control': 'max-age=600', |
| 90 | + etag: '"abc123"', |
| 91 | + 'last-modified': 'Mon, 01 Jan 2024 00:00:00 GMT', |
| 92 | + expires: 'Thu, 01 Jan 2099 00:00:00 GMT', |
| 93 | + 'x-custom': 'value', |
| 94 | + }); |
| 95 | + |
| 96 | + assert.equal(headersSet['content-type'], 'application/json'); |
| 97 | + assert.equal(headersSet['x-custom'], 'value'); |
| 98 | + assert.equal(headersSet['cache-control'], 'public, max-age=30'); |
| 99 | + assert.equal(headersSet.etag, undefined); |
| 100 | + assert.equal(headersSet['last-modified'], undefined); |
| 101 | + assert.equal(headersSet.expires, undefined); |
| 102 | + }); |
| 103 | + |
| 104 | + it('handles null/undefined headers gracefully', () => { |
| 105 | + const headersSet = {}; |
| 106 | + const res = { header(name, value) { headersSet[name] = value; } }; |
| 107 | + |
| 108 | + HttpCache.setFilteredHeaders(res, null); |
| 109 | + assert.equal(headersSet['cache-control'], 'public, max-age=30'); |
| 110 | + }); |
| 111 | +}); |
| 112 | + |
| 113 | +describe('HttpCache.calculateHeuristicMaxAge', () => { |
| 114 | + it('returns 0 for future dates', () => { |
| 115 | + const future = new Date(Date.now() + 3600 * 1000).toUTCString(); |
| 116 | + assert.equal(HttpCache.calculateHeuristicMaxAge(future), 0); |
| 117 | + }); |
| 118 | + |
| 119 | + it('clamps to minimum of 1 hour for recent resources', () => { |
| 120 | + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toUTCString(); |
| 121 | + assert.equal(HttpCache.calculateHeuristicMaxAge(tenMinutesAgo), 3600); |
| 122 | + }); |
| 123 | + |
| 124 | + it('uses 10% of age for mid-range resources', () => { |
| 125 | + const twentyHoursAgo = new Date(Date.now() - 20 * 3600 * 1000).toUTCString(); |
| 126 | + const result = HttpCache.calculateHeuristicMaxAge(twentyHoursAgo); |
| 127 | + // 20 hours = 72000s, 10% = 7200s = 2 hours, within [1h, 4h] |
| 128 | + assert.equal(result, 7200); |
| 129 | + }); |
| 130 | + |
| 131 | + it('clamps to maximum of 4 hours for old resources', () => { |
| 132 | + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toUTCString(); |
| 133 | + assert.equal(HttpCache.calculateHeuristicMaxAge(oneWeekAgo), 4 * 3600); |
| 134 | + }); |
| 135 | + |
| 136 | + it('returns NaN for invalid date strings (Date constructor does not throw)', () => { |
| 137 | + const result = HttpCache.calculateHeuristicMaxAge('not-a-date'); |
| 138 | + assert.ok(Number.isNaN(result)); |
| 139 | + }); |
| 140 | +}); |
| 141 | + |
| 142 | +describe('HttpCache instance - cache state transitions', () => { |
| 143 | + let testCache; |
| 144 | + |
| 145 | + beforeEach(() => { |
| 146 | + testCache = new HttpCache(); |
| 147 | + // Stop the cleanup interval to avoid interference |
| 148 | + if (testCache.cleanupInterval) { |
| 149 | + clearInterval(testCache.cleanupInterval); |
| 150 | + testCache.cleanupInterval = null; |
| 151 | + } |
| 152 | + }); |
| 153 | + |
| 154 | + afterEach(() => { |
| 155 | + if (testCache.cleanupInterval) { |
| 156 | + clearInterval(testCache.cleanupInterval); |
| 157 | + } |
| 158 | + }); |
| 159 | + |
| 160 | + it('returns miss for unknown keys', () => { |
| 161 | + const req = { path: '/api/test', url: '/api/test' }; |
| 162 | + const result = testCache.getCachedRequest(req); |
| 163 | + assert.equal(result.status, 'miss'); |
| 164 | + assert.equal(result.data, null); |
| 165 | + }); |
| 166 | + |
| 167 | + it('returns fresh for non-expired entries', () => { |
| 168 | + const req = { path: '/api/test', url: '/api/test' }; |
| 169 | + const key = HttpCache.generateKey(req); |
| 170 | + |
| 171 | + testCache.cache.set(key, { |
| 172 | + statusCode: 200, |
| 173 | + headers: { 'content-type': 'application/json' }, |
| 174 | + data: '{"ok":true}', |
| 175 | + expiry: Date.now() + 60000, |
| 176 | + timestamp: Date.now(), |
| 177 | + url: 'https://api.weather.gov/api/test', |
| 178 | + }); |
| 179 | + |
| 180 | + const result = testCache.getCachedRequest(req); |
| 181 | + assert.equal(result.status, 'fresh'); |
| 182 | + assert.equal(result.data.statusCode, 200); |
| 183 | + }); |
| 184 | + |
| 185 | + it('returns stale for expired entries', () => { |
| 186 | + const req = { path: '/api/test', url: '/api/test' }; |
| 187 | + const key = HttpCache.generateKey(req); |
| 188 | + |
| 189 | + testCache.cache.set(key, { |
| 190 | + statusCode: 200, |
| 191 | + headers: { 'content-type': 'application/json' }, |
| 192 | + data: '{"ok":true}', |
| 193 | + expiry: Date.now() - 1000, |
| 194 | + timestamp: Date.now() - 61000, |
| 195 | + url: 'https://api.weather.gov/api/test', |
| 196 | + }); |
| 197 | + |
| 198 | + const result = testCache.getCachedRequest(req); |
| 199 | + assert.equal(result.status, 'stale'); |
| 200 | + assert.equal(result.data.statusCode, 200); |
| 201 | + }); |
| 202 | + |
| 203 | + it('storeCachedResponse stores entry with explicit TTL', () => { |
| 204 | + const req = { path: '/api/test', url: '/api/test' }; |
| 205 | + const response = { statusCode: 200, headers: {}, data: '{"ok":true}' }; |
| 206 | + const originalHeaders = { 'cache-control': 'max-age=300' }; |
| 207 | + |
| 208 | + testCache.storeCachedResponse(req, response, 'https://api.weather.gov/api/test', originalHeaders); |
| 209 | + |
| 210 | + const key = HttpCache.generateKey(req); |
| 211 | + const cached = testCache.cache.get(key); |
| 212 | + assert.ok(cached); |
| 213 | + assert.equal(cached.statusCode, 200); |
| 214 | + assert.ok(cached.expiry > Date.now()); |
| 215 | + assert.ok(cached.expiry <= Date.now() + 300 * 1000 + 100); |
| 216 | + }); |
| 217 | + |
| 218 | + it('storeCachedResponse does not cache when no cache directives', () => { |
| 219 | + const req = { path: '/api/nocache', url: '/api/nocache' }; |
| 220 | + const response = { statusCode: 200, headers: {}, data: '{}' }; |
| 221 | + |
| 222 | + testCache.storeCachedResponse(req, response, 'https://api.weather.gov/api/nocache', {}); |
| 223 | + |
| 224 | + const key = HttpCache.generateKey(req); |
| 225 | + assert.equal(testCache.cache.has(key), false); |
| 226 | + }); |
| 227 | + |
| 228 | + it('getStats returns correct counts', () => { |
| 229 | + const now = Date.now(); |
| 230 | + testCache.cache.set('valid', { expiry: now + 60000 }); |
| 231 | + testCache.cache.set('expired', { expiry: now - 1000 }); |
| 232 | + |
| 233 | + const stats = testCache.getStats(); |
| 234 | + assert.equal(stats.total, 2); |
| 235 | + assert.equal(stats.valid, 1); |
| 236 | + assert.equal(stats.expired, 1); |
| 237 | + assert.equal(stats.inFlight, 0); |
| 238 | + }); |
| 239 | + |
| 240 | + it('clearEntry removes a specific entry', () => { |
| 241 | + testCache.cache.set('/api/test', { data: 'test' }); |
| 242 | + assert.equal(testCache.cache.size, 1); |
| 243 | + |
| 244 | + const result = testCache.clearEntry('/api/test'); |
| 245 | + assert.equal(result, true); |
| 246 | + assert.equal(testCache.cache.size, 0); |
| 247 | + }); |
| 248 | + |
| 249 | + it('clearEntry returns false for missing entry', () => { |
| 250 | + const result = testCache.clearEntry('/api/nonexistent'); |
| 251 | + assert.equal(result, false); |
| 252 | + }); |
| 253 | +}); |
0 commit comments