thenavisapp
Version:
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
620 lines (474 loc) • 16.3 kB
text/typescript
import {Buffer} from 'node:buffer';
import {promisify} from 'node:util';
import {Readable as ReadableStream} from 'node:stream';
import {Agent} from 'node:http';
import {gzip} from 'node:zlib';
import process from 'node:process';
import test from 'ava';
import {pEvent} from 'p-event';
import getStream from 'get-stream';
import type {Handler} from 'express';
import nock from 'nock';
import CacheableLookup from 'cacheable-lookup';
import delay from 'delay';
import got, {CacheError, type Response} from '../source/index.js';
import withServer from './helpers/with-server.js';
const cacheEndpoint: Handler = (_request, response) => {
response.setHeader('Cache-Control', 'public, max-age=60');
response.end(Date.now().toString());
};
test('non-cacheable responses are not cached', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.setHeader('Cache-Control', 'public, no-cache, no-store');
response.end(Date.now().toString());
});
const cache = new Map();
const firstResponseInt = Number((await got({cache})).body);
const secondResponseInt = Number((await got({cache})).body);
t.is(cache.size, 0);
t.true(firstResponseInt < secondResponseInt);
});
test('cacheable responses are cached', withServer, async (t, server, got) => {
server.get('/', cacheEndpoint);
const cache = new Map();
const firstResponse = await got({cache});
const secondResponse = await got({cache});
t.is(cache.size, 1);
t.is(firstResponse.body, secondResponse.body);
});
test('cacheable responses to POST requests are cached', withServer, async (t, server, got) => {
server.post('/', cacheEndpoint);
const cache = new Map();
const firstResponse = await got({method: 'POST', body: 'test', cache});
const secondResponse = await got({method: 'POST', body: 'test', cache});
t.is(cache.size, 1);
t.is(firstResponse.body, secondResponse.body);
});
test('non-cacheable responses to POST requests are not cached', withServer, async (t, server, got) => {
server.post('/', cacheEndpoint);
const cache = new Map();
// POST requests with streams are not cached
const body1 = ReadableStream.from(Buffer.from([1, 2, 3]));
const body2 = ReadableStream.from(Buffer.from([1, 2, 3]));
const firstResponseInt = Number((await got({method: 'POST', body: body1, cache})).body);
const secondResponseInt = Number((await got({method: 'POST', body: body2, cache})).body);
t.is(cache.size, 0);
t.true(firstResponseInt < secondResponseInt);
});
test('cached response is re-encoded to current encoding option', withServer, async (t, server, got) => {
server.get('/', cacheEndpoint);
const cache = new Map();
const firstEncoding = 'base64';
const secondEncoding = 'hex';
const firstResponse = await got({cache, encoding: firstEncoding});
const secondResponse = await got({cache, encoding: secondEncoding});
const expectedSecondResponseBody = Buffer.from(firstResponse.body, firstEncoding).toString(secondEncoding);
t.is(cache.size, 1);
t.is(secondResponse.body, expectedSecondResponseBody);
});
test('redirects are cached and re-used internally', withServer, async (t, server, got) => {
let status301Index = 0;
server.get('/301', (_request, response) => {
if (status301Index === 0) {
response.setHeader('Cache-Control', 'public, max-age=60');
response.setHeader('Location', '/');
response.statusCode = 301;
}
response.end();
status301Index++;
});
let status302Index = 0;
server.get('/302', (_request, response) => {
if (status302Index === 0) {
response.setHeader('Cache-Control', 'public, max-age=60');
response.setHeader('Location', '/');
response.statusCode = 302;
}
response.end();
status302Index++;
});
server.get('/', cacheEndpoint);
const cache = new Map();
const a1 = await got('301', {cache});
const b1 = await got('302', {cache});
const a2 = await got('301', {cache});
const b2 = await got('302', {cache});
t.is(cache.size, 3);
t.is(a1.body, b1.body);
t.is(a1.body, a2.body);
t.is(b1.body, b2.body);
});
test('cached response has got options', withServer, async (t, server, got) => {
server.get('/', cacheEndpoint);
const cache = new Map();
const options = {
username: 'foo',
cache,
};
await got(options);
const secondResponse = await got(options);
t.is(secondResponse.request.options.username, options.username);
});
test('cache error throws `CacheError`', withServer, async (t, server, got) => {
server.get('/', (_request, response) => {
response.end('ok');
});
const cache = {};
// @ts-expect-error Error tests
await t.throwsAsync(got({cache}), {
instanceOf: CacheError,
code: 'ERR_CACHE_ACCESS',
});
});
test('doesn\'t cache response when received HTTP error', withServer, async (t, server, got) => {
let isFirstErrorCalled = false;
server.get('/', (_request, response) => {
if (!isFirstErrorCalled) {
response.end('ok');
return;
}
isFirstErrorCalled = true;
response.statusCode = 502;
response.end('received 502');
});
const cache = new Map();
const {statusCode, body} = await got({url: '', cache, throwHttpErrors: false});
t.is(statusCode, 200);
t.is(body, 'ok');
});
test('DNS cache works', async t => {
const instance = got.extend({
dnsCache: true,
});
await t.notThrowsAsync(instance('https://example.com'));
// @ts-expect-error Accessing private property
t.is(instance.defaults.options.dnsCache!._cache.size, 1);
});
test('DNS cache works - CacheableLookup instance', async t => {
const cache = new CacheableLookup();
await t.notThrowsAsync(got('https://example.com', {dnsCache: cache}));
t.is((cache as any)._cache.size, 1);
});
test('`isFromCache` stream property is undefined before the `response` event', withServer, async (t, server, got) => {
server.get('/', cacheEndpoint);
const cache = new Map();
const stream = got.stream({cache});
t.is(stream.isFromCache, undefined);
await getStream(stream);
});
test('`isFromCache` stream property is false after the `response` event', withServer, async (t, server, got) => {
server.get('/', cacheEndpoint);
const cache = new Map();
const stream = got.stream({cache});
const response: Response = await pEvent(stream, 'response');
t.is(response.isFromCache, false);
t.is(stream.isFromCache, false);
await getStream(stream);
});
test('`isFromCache` stream property is true if the response was cached', withServer, async (t, server, got) => {
server.get('/', cacheEndpoint);
const cache = new Map();
await getStream(got.stream({cache}));
const stream = got.stream({cache});
const response: Response = await pEvent(stream, 'response');
t.is(response.isFromCache, true);
t.is(stream.isFromCache, true);
await getStream(stream);
});
test('can disable cache by extending the instance', withServer, async (t, server, got) => {
server.get('/', cacheEndpoint);
const cache = new Map();
const instance = got.extend({cache});
await getStream(instance.stream(''));
const stream = instance.extend({cache: false}).stream('');
const response: Response = await pEvent(stream, 'response');
t.is(response.isFromCache, false);
t.is(stream.isFromCache, false);
await getStream(stream);
});
test('does not break POST requests', withServer, async (t, server, got) => {
server.post('/', async (request, response) => {
request.resume();
response.end(JSON.stringify(request.headers));
});
const headers = await got.post('', {
body: '',
cache: new Map(),
}).json<{'content-length': string}>();
t.is(headers['content-length'], '0');
});
test('decompresses cached responses', withServer, async (t, server, got) => {
const etag = 'foobar';
const payload = JSON.stringify({foo: 'bar'});
const compressed = await promisify(gzip)(payload);
server.get('/', (request, response) => {
if (request.headers['if-none-match'] === etag) {
response.statusCode = 304;
response.end();
} else {
response.setHeader('content-encoding', 'gzip');
response.setHeader('cache-control', 'public, max-age=60');
response.setHeader('etag', etag);
response.end(compressed);
}
});
const cache = new Map();
for (let i = 0; i < 2; i++) {
// eslint-disable-next-line no-await-in-loop
await t.notThrowsAsync(got({
cache,
responseType: 'json',
decompress: true,
retry: {
limit: 2,
},
}));
}
t.is(cache.size, 1);
});
test('can replace the instance\'s HTTP cache', withServer, async (t, server, got) => {
server.get('/', cacheEndpoint);
const cache = new Map();
const secondCache = new Map();
const instance = got.extend({
mutableDefaults: true,
cache,
});
await t.notThrowsAsync(instance(''));
await t.notThrowsAsync(instance(''));
instance.defaults.options.cache = secondCache;
await t.notThrowsAsync(instance(''));
await t.notThrowsAsync(instance(''));
t.is(cache.size, 1);
t.is(secondCache.size, 1);
});
test('does not hang on huge response', withServer, async (t, server, got) => {
const bufferSize = 3 * 16 * 1024;
const times = 10;
const buffer = Buffer.alloc(bufferSize);
server.get('/', async (_request, response) => {
for (let i = 0; i < 10; i++) {
response.write(buffer);
// eslint-disable-next-line no-await-in-loop
await delay(100);
}
response.end();
});
const body = await got('', {
cache: new Map(),
}).buffer();
t.is(body.length, bufferSize * times);
});
test('cached response ETag', withServer, async (t, server, got) => {
const etag = 'foobar';
const body = 'responseBody';
server.get('/', (request, response) => {
if (request.headers['if-none-match'] === etag) {
response.writeHead(304);
response.end();
} else {
response.writeHead(200, {etag});
response.end(body);
}
});
const cache = new Map();
const originalResponse = await got({cache});
t.false(originalResponse.isFromCache);
t.is(originalResponse.body, body);
await delay(100); // Added small delay in order to wait the cache to be populated
t.is(cache.size, 1);
const cachedResponse = await got({cache});
t.true(cachedResponse.isFromCache);
t.is(cachedResponse.body, body);
});
// TODO: The test is flaky.
// test('works with http2', async t => {
// const cache = new Map();
// const client = got.extend({
// http2: true,
// cache,
// });
// try {
// await client('https://httpbin.org/anything');
// t.pass();
// } catch (error: any) {
// if (error.message.includes('install Node.js')) {
// t.pass();
// return;
// }
// t.fail(error.message);
// }
// });
test('http-cache-semantics typings', t => {
const instance = got.extend({
cacheOptions: {
shared: false,
},
});
t.is(instance.defaults.options.cacheOptions.shared, false);
});
test('allows internal modifications', async t => {
nock('http://example.com').get('/test').reply(401);
nock('http://example.com').get('/test').reply(200, JSON.stringify({
wat: ['123'],
}));
const client = got.extend({
cache: new Map(),
hooks: {
afterResponse: [
async (response, retryWithMergedOptions) => {
if (response.statusCode === 401) {
return retryWithMergedOptions({});
}
return response;
},
],
},
});
await t.notThrowsAsync(client.get('http://example.com/test'));
});
test('response.complete is true when using keepalive agent', withServer, async (t, server, got) => {
const agent = {
http: new Agent({keepAlive: true}),
};
const etag = 'foobar';
const payload = JSON.stringify({foo: 'bar'});
const compressed = await promisify(gzip)(payload);
server.get('/', (request, response) => {
if (request.headers['if-none-match'] === etag) {
response.statusCode = 304;
response.end();
} else {
response.setHeader('content-encoding', 'gzip');
response.setHeader('cache-control', 'public, max-age=60');
response.setHeader('etag', etag);
response.end(compressed);
}
});
const cache = new Map();
const first = await got({
cache,
responseType: 'json',
decompress: true,
retry: {
limit: 2,
},
agent,
});
t.true(first.complete);
});
test.failing('revalidated uncompressed responses are retrieved from cache', withServer, async (t, server, got) => {
let revalidated = false;
const payload = JSON.stringify([1]);
server.get('/', (request, response) => {
if (request.headers['if-none-match'] === 'asdf') {
revalidated = true;
response.writeHead(304, {etag: 'asdf'});
response.end();
} else {
response.writeHead(200, {
etag: 'asdf',
'cache-control': 'public, max-age=1, s-maxage=1',
'content-type': 'application/json',
});
response.write(payload);
response.end();
}
});
t.timeout(5000);
const client = got.extend({cache: new Map(), responseType: 'json'});
await client('').then(response => {
t.false(revalidated);
t.deepEqual(response.body, [1]);
t.true(response.complete);
});
// eslint-disable-next-line no-promise-executor-return
await new Promise(resolve => setTimeout(resolve, 3000));
console.log('max-age has expired, performing second request');
await client('').then(response => {
t.true(revalidated);
t.deepEqual(response.body, [1]);
t.true(response.complete); // Fails here.
});
});
test.failing('revalidated compressed responses are retrieved from cache', withServer, async (t, server, got) => {
let revalidated = false;
const payload = JSON.stringify([1]);
const compressed = await promisify(gzip)(payload);
server.get('/', (request, response) => {
if (request.headers['if-none-match'] === 'asdf') {
revalidated = true;
response.writeHead(304, {etag: 'asdf'});
response.end();
} else {
response.writeHead(200, {
etag: 'asdf',
'cache-control': 'public, max-age=1, s-maxage=1',
'content-type': 'application/json',
'content-encoding': 'gzip',
});
response.write(compressed);
response.end();
}
});
t.timeout(5000);
const client = got.extend({cache: new Map(), responseType: 'json'});
await client('').then(response => {
t.false(revalidated);
t.deepEqual(response.body, [1]);
t.true(response.complete);
});
// eslint-disable-next-line no-promise-executor-return
await new Promise(resolve => setTimeout(resolve, 3000));
console.log('max-age has expired, performing second request (but it will actually hang)');
await client('').then(response => {
t.true(revalidated);
t.deepEqual(response.body, [1]);
t.true(response.complete);
});
});
// eslint-disable-next-line ava/no-skip-test -- Unreliable
test.skip('revalidated uncompressed responses from github are retrieved from cache', async t => {
const client = got.extend({
cache: new Map(),
cacheOptions: {shared: false},
responseType: 'json',
headers: {
'accept-encoding': 'identity',
...(process.env.GITHUB_TOKEN ? {authorization: `token ${process.env.GITHUB_TOKEN}`} : {}),
},
});
t.timeout(70_000);
await client('https://api.github.com/repos/octocat/Spoon-Knife').then(response => {
t.is((response.body as any).name, 'Spoon-Knife');
t.true(response.complete);
});
// eslint-disable-next-line no-promise-executor-return
await new Promise(resolve => setTimeout(resolve, 65_000));
console.log('max-age has expired, performing second request');
await client('https://api.github.com/repos/octocat/Spoon-Knife').then(response => {
t.is((response.body as any).name, 'Spoon-Knife');
t.true(response.complete); // Fails here.
});
});
// eslint-disable-next-line ava/no-skip-test -- Unreliable
test.skip('revalidated compressed responses from github are retrieved from cache', async t => {
const client = got.extend({
cache: new Map(),
cacheOptions: {shared: false},
responseType: 'json',
headers: process.env.GITHUB_TOKEN ? {authorization: `token ${process.env.GITHUB_TOKEN}`} : {},
});
t.timeout(70_000);
await client('https://api.github.com/repos/octocat/Spoon-Knife').then(response => {
t.is((response.body as any).name, 'Spoon-Knife');
t.true(response.complete);
});
// eslint-disable-next-line no-promise-executor-return
await new Promise(resolve => setTimeout(resolve, 65_000));
console.log('max-age has expired, performing second request (but it will actually hang)');
await client('https://api.github.com/repos/octocat/Spoon-Knife').then(response => {
t.is((response.body as any).name, 'Spoon-Knife');
t.true(response.complete);
});
});