From a0b52e39cafc1d96fe498e8c077c61659df17838 Mon Sep 17 00:00:00 2001 From: flakey5 <73616808+flakey5@users.noreply.github.com> Date: Fri, 18 Oct 2024 18:21:46 -0700 Subject: [PATCH] add tests Signed-off-by: flakey5 <73616808+flakey5@users.noreply.github.com> --- lib/interceptor/cache.js | 20 ++- test/interceptors/cache.js | 354 ++++++++++++++++++++++++++++++++++++- 2 files changed, 365 insertions(+), 9 deletions(-) diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js index df8da1d9686..6a5c7076267 100644 --- a/lib/interceptor/cache.js +++ b/lib/interceptor/cache.js @@ -61,8 +61,12 @@ function needsRevalidation (now, value, age, cacheControlDirectives) { if (cacheControlDirectives?.['min-fresh']) { // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3 - const gracePeriod = age + (cacheControlDirectives['min-fresh'] * 1000) - return (now - value.staleAt) > gracePeriod + + // At this point, staleAt is always > now + const timeLeftTillStale = value.staleAt - now + const threshold = cacheControlDirectives['min-fresh'] * 1000 + + return timeLeftTillStale <= threshold } return false @@ -119,16 +123,19 @@ module.exports = (opts = {}) => { return dispatch(opts, new CacheHandler(globalOpts, opts, handler)) } - let onErrorCalled = false + const now = Date.now() /** * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreReadable} stream * @param {import('../../types/cache-interceptor.d.ts').default.CacheStoreValue} value + * @param {number} age */ - const respondWithCachedValue = (stream, value) => { + const respondWithCachedValue = (stream, value, age) => { const ac = new AbortController() const signal = ac.signal + let onErrorCalled = false + signal.onabort = (_, err) => { stream.destroy() if (!onErrorCalled) { @@ -153,8 +160,6 @@ module.exports = (opts = {}) => { if (typeof handler.onHeaders === 'function') { // Add the age header // https://www.rfc-editor.org/rfc/rfc9111.html#name-age - const age = Math.round((Date.now() - value.cachedAt) / 1000) - value.rawHeaders.push(AGE_HEADER, Buffer.from(`${age}`)) handler.onHeaders(value.statusCode, value.rawHeaders, stream.resume, value.statusMessage) @@ -209,7 +214,6 @@ module.exports = (opts = {}) => { const { value } = stream - const now = Date.now() const age = Math.round((now - value.cachedAt) / 1000) if (requestCacheControl?.['max-age'] && age >= requestCacheControl['max-age']) { // Response is considered expired for this specific request @@ -250,7 +254,7 @@ module.exports = (opts = {}) => { return } - respondWithCachedValue(stream, value) + respondWithCachedValue(stream, value, age) } Promise.resolve(stream).then(handleStream).catch(handler.onError) diff --git a/test/interceptors/cache.js b/test/interceptors/cache.js index 302fc734b4b..1b5fc62ab2e 100644 --- a/test/interceptors/cache.js +++ b/test/interceptors/cache.js @@ -1,7 +1,7 @@ 'use strict' const { describe, test, after } = require('node:test') -const { strictEqual, notEqual, fail } = require('node:assert') +const { strictEqual, notEqual, fail, equal } = require('node:assert') const { createServer } = require('node:http') const { once } = require('node:events') const FakeTimers = require('@sinonjs/fake-timers') @@ -250,4 +250,356 @@ describe('Cache Interceptor', () => { } }) }) + + describe('Client-side directives', () => { + test('max-age', async () => { + const clock = FakeTimers.install({ + shouldClearNativeTimers: true + }) + + let requestsToOrigin = 0 + const server = createServer((_, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'public, s-maxage=100') + res.end('asd') + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + clock.uninstall() + server.close() + await client.close() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + // Send initial request. This should reach the origin + let response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/' + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Send second request that should be handled by cache + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/' + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + strictEqual(response.headers.age, '0') + + // Send third request w/ the directive, this should be handled by the cache + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'max-age=5' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + clock.tick(6000) + + // Send fourth request w/ the directive, age should be 6 now so this + // should hit the origin + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'max-age=5' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + }) + + test('max-stale', async () => { + let requestsToOrigin = 0 + + const clock = FakeTimers.install({ + shouldClearNativeTimers: true + }) + + const server = createServer((req, res) => { + res.setHeader('cache-control', 'public, s-maxage=1, stale-while-revalidate=10') + + if (requestsToOrigin === 1) { + notEqual(req.headers['if-modified-since'], undefined) + + res.statusCode = 304 + res.end() + } else { + res.end('asd') + } + + requestsToOrigin++ + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await client.close() + clock.uninstall() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // Send initial request. This should reach the origin + let response = await client.request(request) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + clock.tick(1500) + + // Now we send a second request. This should be within the max stale + // threshold, so a request shouldn't be made to the origin + response = await client.request({ + ...request, + headers: { + 'cache-control': 'max-stale=5' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Send a third request. This shouldn't be within the max stale threshold + // so a request should be made to the origin + response = await client.request({ + ...request, + headers: { + 'cache-control': 'max-stale=0' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + }) + + test('min-fresh', async () => { + let requestsToOrigin = 0 + + const clock = FakeTimers.install({ + shouldClearNativeTimers: true + }) + + const server = createServer((req, res) => { + requestsToOrigin++ + res.setHeader('cache-control', 'public, s-maxage=10') + res.end('asd') + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await client.close() + clock.uninstall() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + const request = { + origin: 'localhost', + method: 'GET', + path: '/' + } + + // Send initial request. This should reach the origin + let response = await client.request(request) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Fast forward more. Response has 8sec TTL left after + clock.tick(2000) + + // Now we send a second request. This should be within the threshold, so + // a request shouldn't be made to the origin + response = await client.request({ + ...request, + headers: { + 'cache-control': 'min-fresh=5' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Fast forward more. Response has 2sec TTL left after + clock.tick(6000) + + // Send the second request again, this time it shouldn't be within the + // threshold and a request should be made to the origin. + response = await client.request({ + ...request, + headers: { + 'cache-control': 'min-fresh=5' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + }) + + test('no-cache', async () => { + let requestsToOrigin = 0 + const server = createServer((req, res) => { + if (requestsToOrigin === 1) { + notEqual(req.headers['if-modified-since'], undefined) + res.statusCode = 304 + res.end() + } else { + res.setHeader('cache-control', 'public, s-maxage=100') + res.end('asd') + } + + requestsToOrigin++ + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await client.close() + }) + + await once(server, 'listening') + + strictEqual(requestsToOrigin, 0) + + // Send initial request. This should reach the origin + let response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'no-cache' + } + }) + strictEqual(requestsToOrigin, 1) + strictEqual(await response.body.text(), 'asd') + + // Send second request, a validation request should be sent + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'no-cache' + } + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + + // Send third request w/o no-cache, this should be handled by the cache + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/' + }) + strictEqual(requestsToOrigin, 2) + strictEqual(await response.body.text(), 'asd') + }) + + test('no-store', async () => { + const server = createServer((req, res) => { + res.setHeader('cache-control', 'public, s-maxage=100') + res.end('asd') + }).listen(0) + + const store = new cacheStores.MemoryCacheStore() + store.createWriteStream = (...args) => { + fail('shouln\'t have reached this') + } + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache({ store })) + + after(async () => { + server.close() + await client.close() + }) + + await once(server, 'listening') + + // Send initial request. This should reach the origin + const response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'no-store' + } + }) + strictEqual(await response.body.text(), 'asd') + }) + + test('only-if-cached', async () => { + const server = createServer((req, res) => { + res.setHeader('cache-control', 'public, s-maxage=100') + res.end('asd') + }).listen(0) + + const client = new Client(`http://localhost:${server.address().port}`) + .compose(interceptors.cache()) + + after(async () => { + server.close() + await client.close() + }) + + await once(server, 'listening') + + // Send initial request. This should reach the origin + let response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/' + }) + strictEqual(await response.body.text(), 'asd') + + // Send second request, this shouldn't reach the origin + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/', + headers: { + 'cache-control': 'only-if-cached' + } + }) + strictEqual(await response.body.text(), 'asd') + + // Send third request to an uncached resource, this should return a 504 + response = await client.request({ + origin: 'localhost', + method: 'GET', + path: '/bla', + headers: { + 'cache-control': 'only-if-cached' + } + }) + equal(response.statusCode, 504) + }) + }) })