feat(rest): use undici (#7747)

Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
Co-authored-by: ckohen <chaikohen@gmail.com>
This commit is contained in:
Khafra
2022-05-12 16:49:15 -04:00
committed by GitHub
parent 4515a1ea80
commit d1ec8c37ff
19 changed files with 964 additions and 605 deletions

View File

@@ -66,6 +66,10 @@ test('guildMemberAvatar dynamic-not-animated', () => {
expect(cdn.guildMemberAvatar(id, id, hash)).toBe(`${base}/guilds/${id}/users/${id}/avatars/${hash}.webp`);
});
test('guildScheduledEventCover default', () => {
expect(cdn.guildScheduledEventCover(id, hash)).toBe(`${base}/guild-events/${id}/${hash}.webp`);
});
test('icon default', () => {
expect(cdn.icon(id, hash)).toBe(`${base}/icons/${id}/${hash}.webp`);
});

View File

@@ -1,202 +1,224 @@
import { DiscordSnowflake } from '@sapphire/snowflake';
import { Routes, Snowflake } from 'discord-api-types/v10';
import nock from 'nock';
import { Response } from 'node-fetch';
import { REST, DefaultRestOptions, APIRequest } from '../src';
import { File, FormData, MockAgent, setGlobalDispatcher } from 'undici';
import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor';
import { genPath } from './util';
import { REST } from '../src';
const newSnowflake: Snowflake = DiscordSnowflake.generate().toString();
const api = new REST().setToken('A-Very-Fake-Token');
nock(`${DefaultRestOptions.api}/v${DefaultRestOptions.version}`)
.get('/simpleGet')
.reply(200, { test: true })
.delete('/simpleDelete')
.reply(200, { test: true })
.patch('/simplePatch')
.reply(200, { test: true })
.put('/simplePut')
.reply(200, { test: true })
.post('/simplePost')
.reply(200, { test: true })
.get('/getQuery')
.query({ foo: 'bar', hello: 'world' })
.reply(200, { test: true })
.get('/getAuth')
.times(3)
.reply(200, function handler() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return { auth: this.req.headers.authorization?.[0] ?? null };
})
.get('/getReason')
.times(3)
.reply(200, function handler() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return { reason: this.req.headers['x-audit-log-reason']?.[0] ?? null };
})
.post('/urlEncoded')
.reply(200, (_, body) => body)
.post('/postEcho')
.reply(200, (_, body) => body)
.post('/postFile')
.times(5)
.reply(200, (_, body) => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
body: body
.replace(/\r\n/g, '\n')
.replace(/-+\d+-*\n?/g, '')
.trim(),
}))
.delete('/channels/339942739275677727/messages/392063687801700356')
.reply(200, { test: true })
.delete(`/channels/339942739275677727/messages/${newSnowflake}`)
.reply(200, { test: true })
.get('/request')
.times(2)
.reply(200, { test: true });
// @discordjs/rest uses the `content-type` header to detect whether to parse
// the response as JSON or as an ArrayBuffer.
const responseOptions: MockInterceptor.MockResponseOptions = {
headers: {
'content-type': 'application/json',
},
};
let mockAgent: MockAgent;
let mockPool: Interceptable;
beforeEach(() => {
mockAgent = new MockAgent();
mockAgent.disableNetConnect(); // prevent actual requests to Discord
setGlobalDispatcher(mockAgent); // enabled the mock client to intercept requests
mockPool = mockAgent.get('https://discord.com');
});
afterEach(async () => {
await mockAgent.close();
});
test('simple GET', async () => {
mockPool
.intercept({
path: genPath('/simpleGet'),
method: 'GET',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.get('/simpleGet')).toStrictEqual({ test: true });
});
test('simple DELETE', async () => {
mockPool
.intercept({
path: genPath('/simpleDelete'),
method: 'DELETE',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.delete('/simpleDelete')).toStrictEqual({ test: true });
});
test('simple PATCH', async () => {
mockPool
.intercept({
path: genPath('/simplePatch'),
method: 'PATCH',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.patch('/simplePatch')).toStrictEqual({ test: true });
});
test('simple PUT', async () => {
mockPool
.intercept({
path: genPath('/simplePut'),
method: 'PUT',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.put('/simplePut')).toStrictEqual({ test: true });
});
test('simple POST', async () => {
mockPool
.intercept({
path: genPath('/simplePost'),
method: 'POST',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.post('/simplePost')).toStrictEqual({ test: true });
});
test('simple PUT', async () => {
mockPool
.intercept({
path: genPath('/simplePut'),
method: 'PUT',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.put('/simplePut')).toStrictEqual({ test: true });
});
test('getQuery', async () => {
const query = new URLSearchParams([
['foo', 'bar'],
['hello', 'world'],
]);
mockPool
.intercept({
path: `${genPath('/getQuery')}?${query.toString()}`,
method: 'GET',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(
await api.get('/getQuery', {
query: new URLSearchParams([
['foo', 'bar'],
['hello', 'world'],
]),
query: query,
}),
).toStrictEqual({ test: true });
});
test('getAuth default', async () => {
test('getAuth', async () => {
mockPool
.intercept({
path: genPath('/getAuth'),
method: 'GET',
})
.reply((t) => ({
data: { auth: (t.headers as unknown as Record<string, string | undefined>)['Authorization'] ?? null },
statusCode: 200,
responseOptions,
}))
.times(3);
// default
expect(await api.get('/getAuth')).toStrictEqual({ auth: 'Bot A-Very-Fake-Token' });
// unauthorized
expect(
await api.get('/getAuth', {
auth: false,
}),
).toStrictEqual({ auth: null });
// authorized
expect(
await api.get('/getAuth', {
auth: true,
}),
).toStrictEqual({ auth: 'Bot A-Very-Fake-Token' });
});
test('getAuth unauthorized', async () => {
expect(await api.get('/getAuth', { auth: false })).toStrictEqual({ auth: null });
});
test('getReason', async () => {
mockPool
.intercept({
path: genPath('/getReason'),
method: 'GET',
})
.reply((t) => ({
data: { reason: (t.headers as unknown as Record<string, string | undefined>)['X-Audit-Log-Reason'] ?? null },
statusCode: 200,
responseOptions,
}))
.times(3);
test('getAuth authorized', async () => {
expect(await api.get('/getAuth', { auth: true })).toStrictEqual({ auth: 'Bot A-Very-Fake-Token' });
});
test('getReason default', async () => {
// default
expect(await api.get('/getReason')).toStrictEqual({ reason: null });
});
test('getReason plain text', async () => {
expect(await api.get('/getReason', { reason: 'Hello' })).toStrictEqual({ reason: 'Hello' });
});
test('getReason encoded', async () => {
expect(await api.get('/getReason', { reason: '😄' })).toStrictEqual({ reason: '%F0%9F%98%84' });
});
test('postFile empty', async () => {
expect(await api.post('/postFile', { files: [] })).toStrictEqual({
body: '',
});
});
test('postFile file (string)', async () => {
// plain text
expect(
await api.post('/postFile', {
files: [{ name: 'out.txt', data: 'Hello' }],
await api.get('/getReason', {
reason: 'Hello',
}),
).toStrictEqual({
body: [
'Content-Disposition: form-data; name="files[0]"; filename="out.txt"',
'Content-Type: text/plain',
'',
'Hello',
].join('\n'),
});
});
).toStrictEqual({ reason: 'Hello' });
test('postFile file and JSON', async () => {
// encoded
expect(
await api.post('/postFile', {
files: [{ name: 'out.txt', data: Buffer.from('Hello') }],
body: { foo: 'bar' },
await api.get('/getReason', {
reason: '😄',
}),
).toStrictEqual({
body: [
'Content-Disposition: form-data; name="files[0]"; filename="out.txt"',
'Content-Type: text/plain',
'',
'Hello',
'Content-Disposition: form-data; name="payload_json"',
'',
'{"foo":"bar"}',
].join('\n'),
});
});
test('postFile files and JSON', async () => {
expect(
await api.post('/postFile', {
files: [
{ name: 'out.txt', data: Buffer.from('Hello') },
{ name: 'out.txt', data: Buffer.from('Hi') },
],
body: { files: [{ id: 0, description: 'test' }] },
}),
).toStrictEqual({
body: [
'Content-Disposition: form-data; name="files[0]"; filename="out.txt"',
'Content-Type: text/plain',
'',
'Hello',
'Content-Disposition: form-data; name="files[1]"; filename="out.txt"',
'Content-Type: text/plain',
'',
'Hi',
'Content-Disposition: form-data; name="payload_json"',
'',
'{"files":[{"id":0,"description":"test"}]}',
].join('\n'),
});
});
test('postFile sticker and JSON', async () => {
expect(
await api.post('/postFile', {
files: [{ key: 'file', name: 'sticker.png', data: Buffer.from('Sticker') }],
body: { foo: 'bar' },
appendToFormData: true,
}),
).toStrictEqual({
body: [
'Content-Disposition: form-data; name="file"; filename="sticker.png"',
'Content-Type: image/png',
'',
'Sticker',
'Content-Disposition: form-data; name="foo"',
'',
'bar',
].join('\n'),
});
).toStrictEqual({ reason: '%F0%9F%98%84' });
});
test('urlEncoded', async () => {
mockPool
.intercept({
path: genPath('/urlEncoded'),
method: 'POST',
})
.reply((t) => ({
data: t.body!,
statusCode: 200,
}));
const body = new URLSearchParams([
['client_id', '1234567890123545678'],
['client_secret', 'totally-valid-secret'],
@@ -204,6 +226,7 @@ test('urlEncoded', async () => {
['grant_type', 'authorization_code'],
['code', 'very-invalid-code'],
]);
expect(
new Uint8Array(
(await api.post('/urlEncoded', {
@@ -216,55 +239,156 @@ test('urlEncoded', async () => {
});
test('postEcho', async () => {
mockPool
.intercept({
path: genPath('/postEcho'),
method: 'POST',
})
.reply((t) => ({
data: t.body!,
statusCode: 200,
responseOptions,
}));
expect(await api.post('/postEcho', { body: { foo: 'bar' } })).toStrictEqual({ foo: 'bar' });
});
test('Old Message Delete Edge-Case: Old message', async () => {
mockPool
.intercept({
path: genPath('/channels/339942739275677727/messages/392063687801700356'),
method: 'DELETE',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.delete(Routes.channelMessage('339942739275677727', '392063687801700356'))).toStrictEqual({
test: true,
});
});
test('Old Message Delete Edge-Case: New message', async () => {
test('Old Message Delete Edge-Case: Old message', async () => {
mockPool
.intercept({
path: genPath(`/channels/339942739275677727/messages/${newSnowflake}`),
method: 'DELETE',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.delete(Routes.channelMessage('339942739275677727', newSnowflake))).toStrictEqual({ test: true });
});
test('Request and Response Events', async () => {
const requestListener = jest.fn();
const responseListener = jest.fn();
test('postFile', async () => {
const mockData = {
statusCode: 200,
data: 'Hello',
};
api.on('request', requestListener);
api.on('response', responseListener);
mockPool
.intercept({
path: genPath('/postFileEmptyArray'),
method: 'POST',
})
.reply(({ body }) => {
expect(body).toBeNull();
return mockData;
});
await api.get('/request');
// postFile empty
await api.post('/postFileEmptyArray', { files: [] });
expect(requestListener).toHaveBeenCalledTimes(1);
expect(responseListener).toHaveBeenCalledTimes(1);
expect(requestListener).toHaveBeenLastCalledWith<[APIRequest]>(
expect.objectContaining({
method: 'get',
path: '/request',
route: '/request',
data: { files: undefined, body: undefined, auth: true },
retries: 0,
}) as APIRequest,
);
expect(responseListener).toHaveBeenLastCalledWith<[APIRequest, Response]>(
expect.objectContaining({
method: 'get',
path: '/request',
route: '/request',
data: { files: undefined, body: undefined, auth: true },
retries: 0,
}) as APIRequest,
expect.objectContaining({ status: 200, statusText: 'OK' }) as Response,
);
mockPool
.intercept({
path: genPath('/postFileStringData'),
method: 'POST',
})
.reply(({ body }) => {
const fd = body as FormData;
api.off('request', requestListener);
api.off('response', responseListener);
expect(fd.get('files[0]')).toBeInstanceOf(File);
expect(fd.get('files[0]')).toHaveProperty('size', 5); // 'Hello'
await api.get('/request');
return mockData;
});
expect(requestListener).toHaveBeenCalledTimes(1);
expect(responseListener).toHaveBeenCalledTimes(1);
// postFile file (string)
await api.post('/postFileStringData', {
files: [{ name: 'out.txt', data: 'Hello' }],
});
mockPool
.intercept({
path: genPath('/postFileBufferWithJson'),
method: 'POST',
})
.reply(({ body }) => {
const fd = body as FormData;
expect(fd.get('files[0]')).toBeInstanceOf(File);
expect(fd.get('files[0]')).toHaveProperty('size', 5); // Buffer.from('Hello')
expect(fd.get('payload_json')).toStrictEqual(JSON.stringify({ foo: 'bar' }));
return mockData;
});
// postFile file and JSON
await api.post('/postFileBufferWithJson', {
files: [{ name: 'out.txt', data: Buffer.from('Hello') }],
body: { foo: 'bar' },
});
mockPool
.intercept({
path: genPath('/postFilesAndJson'),
method: 'POST',
})
.reply(({ body }) => {
const fd = body as FormData;
expect(fd.get('files[0]')).toBeInstanceOf(File);
expect(fd.get('files[1]')).toBeInstanceOf(File);
expect(fd.get('files[0]')).toHaveProperty('size', 5); // Buffer.from('Hello')
expect(fd.get('files[1]')).toHaveProperty('size', 2); // Buffer.from('Hi')
expect(fd.get('payload_json')).toStrictEqual(JSON.stringify({ files: [{ id: 0, description: 'test' }] }));
return mockData;
});
// postFile files and JSON
await api.post('/postFilesAndJson', {
files: [
{ name: 'out.txt', data: Buffer.from('Hello') },
{ name: 'out.txt', data: Buffer.from('Hi') },
],
body: { files: [{ id: 0, description: 'test' }] },
});
mockPool
.intercept({
path: genPath('/postFileStickerAndJson'),
method: 'POST',
})
.reply(({ body }) => {
const fd = body as FormData;
expect(fd.get('file')).toBeInstanceOf(File);
expect(fd.get('file')).toHaveProperty('size', 7); // Buffer.from('Sticker')
expect(fd.get('foo')).toStrictEqual('bar');
return mockData;
});
// postFile sticker and JSON
await api.post('/postFileStickerAndJson', {
files: [{ key: 'file', name: 'sticker.png', data: Buffer.from('Sticker') }],
body: { foo: 'bar' },
appendToFormData: true,
});
});

View File

@@ -1,10 +1,38 @@
import nock from 'nock';
import { DefaultRestOptions, DiscordAPIError, HTTPError, RateLimitError, REST, RESTEvents } from '../src';
import { MockAgent, setGlobalDispatcher } from 'undici';
import type { Interceptable, MockInterceptor } from 'undici/types/mock-interceptor';
import { genPath } from './util';
import { DiscordAPIError, HTTPError, RateLimitError, REST, RESTEvents } from '../src';
let mockAgent: MockAgent;
let mockPool: Interceptable;
const api = new REST({ timeout: 2000, offset: 5 }).setToken('A-Very-Fake-Token');
const invalidAuthApi = new REST({ timeout: 2000 }).setToken('Definitely-Not-A-Fake-Token');
const rateLimitErrorApi = new REST({ rejectOnRateLimit: ['/channels'] }).setToken('Obviously-Not-A-Fake-Token');
beforeEach(() => {
mockAgent = new MockAgent();
mockAgent.disableNetConnect();
setGlobalDispatcher(mockAgent);
mockPool = mockAgent.get('https://discord.com');
api.setAgent(mockAgent);
invalidAuthApi.setAgent(mockAgent);
rateLimitErrorApi.setAgent(mockAgent);
});
afterEach(async () => {
await mockAgent.close();
});
// @discordjs/rest uses the `content-type` header to detect whether to parse
// the response as JSON or as an ArrayBuffer.
const responseOptions: MockInterceptor.MockResponseOptions = {
headers: {
'content-type': 'application/json',
},
};
let resetAfter = 0;
let sublimitResetAfter = 0;
let retryAfter = 0;
@@ -13,7 +41,10 @@ let sublimitHits = 0;
let serverOutage = true;
let unexpected429 = true;
let unexpected429cf = true;
const sublimitIntervals = {
const sublimitIntervals: {
reset: NodeJS.Timer | null;
retry: NodeJS.Timer | null;
} = {
reset: null,
retry: null,
};
@@ -40,187 +71,16 @@ function startSublimitIntervals() {
}
}
nock(`${DefaultRestOptions.api}/v${DefaultRestOptions.version}`)
.persist()
.replyDate()
.get('/standard')
.times(3)
.reply((): nock.ReplyFnResult => {
const response = Date.now() >= resetAfter ? 204 : 429;
resetAfter = Date.now() + 250;
if (response === 204) {
return [
204,
undefined,
{
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
via: '1.1 google',
},
];
}
return [
429,
{
limit: '1',
remaining: '0',
resetAfter: (resetAfter / 1000).toString(),
bucket: '80c17d2f203122d936070c88c8d10f33',
retryAfter: (resetAfter - Date.now()).toString(),
},
{
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
'retry-after': (resetAfter - Date.now()).toString(),
via: '1.1 google',
},
];
})
.get('/triggerGlobal')
.reply(
(): nock.ReplyFnResult => [
204,
{ global: true },
{
'x-ratelimit-global': 'true',
'retry-after': '1',
via: '1.1 google',
},
],
)
.get('/regularRequest')
.reply(204, { test: true })
.patch('/channels/:id', (body) => ['name', 'topic'].some((key) => Reflect.has(body as Record<string, unknown>, key)))
.reply((): nock.ReplyFnResult => {
sublimitHits += 1;
sublimitRequests += 1;
const response = 2 - sublimitHits >= 0 && 10 - sublimitRequests >= 0 ? 204 : 429;
startSublimitIntervals();
if (response === 204) {
return [
204,
undefined,
{
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
},
];
}
return [
429,
{
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1000).toString(),
retryAfter: ((retryAfter - Date.now()) / 1000).toString(),
},
{
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'retry-after': ((retryAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
},
];
})
.patch('/channels/:id', (body) =>
['name', 'topic'].every((key) => !Reflect.has(body as Record<string, unknown>, key)),
)
.reply((): nock.ReplyFnResult => {
sublimitRequests += 1;
const response = 10 - sublimitRequests >= 0 ? 204 : 429;
startSublimitIntervals();
if (response === 204) {
return [
204,
undefined,
{
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
},
];
}
return [
429,
{
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1000).toString(),
retryAfter: ((sublimitResetAfter - Date.now()) / 1000).toString(),
},
{
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'retry-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
},
];
})
.get('/unexpected')
.times(3)
.reply((): nock.ReplyFnResult => {
if (unexpected429) {
unexpected429 = false;
return [
429,
undefined,
{
'retry-after': '1',
via: '1.1 google',
},
];
}
return [204, { test: true }];
})
.get('/unexpected-cf')
.times(2)
.reply((): nock.ReplyFnResult => {
if (unexpected429cf) {
unexpected429cf = false;
return [
429,
undefined,
{
'retry-after': '1',
},
];
}
return [204, { test: true }];
})
.get('/temp')
.times(2)
.reply((): nock.ReplyFnResult => {
if (serverOutage) {
serverOutage = false;
return [500];
}
return [204, { test: true }];
})
.get('/outage')
.times(2)
.reply(500)
.get('/slow')
.times(2)
.delay(3000)
.reply(200)
.get('/badRequest')
.reply(403, { message: 'Missing Permissions', code: 50013 })
.get('/unauthorized')
.reply(401, { message: '401: Unauthorized', code: 0 })
.get('/malformedRequest')
.reply(601);
// This is tested first to ensure the count remains accurate
test('Significant Invalid Requests', async () => {
mockPool
.intercept({
path: genPath('/badRequest'),
method: 'GET',
})
.reply(403, { message: 'Missing Permissions', code: 50013 }, responseOptions)
.times(10);
const invalidListener = jest.fn();
const invalidListener2 = jest.fn();
api.on(RESTEvents.InvalidRequestWarning, invalidListener);
@@ -258,6 +118,54 @@ test('Significant Invalid Requests', async () => {
});
test('Handle standard rate limits', async () => {
mockPool
.intercept({
path: genPath('/standard'),
method: 'GET',
})
.reply(() => {
const response = Date.now() >= resetAfter ? 204 : 429;
resetAfter = Date.now() + 250;
if (response === 204) {
return {
statusCode: 204,
data: '',
responseOptions: {
headers: {
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
via: '1.1 google',
},
},
};
}
return {
statusCode: 429,
data: {
limit: '1',
remaining: '0',
resetAfter: (resetAfter / 1000).toString(),
bucket: '80c17d2f203122d936070c88c8d10f33',
retryAfter: (resetAfter - Date.now()).toString(),
},
responseOptions: {
headers: {
'x-ratelimit-limit': '1',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset-after': ((resetAfter - Date.now()) / 1000).toString(),
'x-ratelimit-bucket': '80c17d2f203122d936070c88c8d10f33',
'retry-after': (resetAfter - Date.now()).toString(),
via: '1.1 google',
},
},
};
})
.times(3);
const [a, b, c] = [api.get('/standard'), api.get('/standard'), api.get('/standard')];
const uint8 = new Uint8Array();
@@ -267,18 +175,107 @@ test('Handle standard rate limits', async () => {
const previous2 = performance.now();
expect(new Uint8Array((await c) as ArrayBuffer)).toStrictEqual(uint8);
const now = performance.now();
expect(previous2).toBeGreaterThanOrEqual(previous1 + 250);
expect(now).toBeGreaterThanOrEqual(previous2 + 250);
});
test('Handle global rate limits', async () => {
const earlier = performance.now();
expect(await api.get('/triggerGlobal')).toStrictEqual({ global: true });
expect(await api.get('/regularRequest')).toStrictEqual({ test: true });
expect(performance.now()).toBeGreaterThanOrEqual(earlier + 100);
expect(previous2).toBeGreaterThanOrEqual(previous1 + 200);
expect(now).toBeGreaterThanOrEqual(previous2 + 200);
});
test('Handle sublimits', async () => {
mockPool
.intercept({
path: genPath('/channels/:id'),
method: 'PATCH',
})
.reply((t) => {
const body = JSON.parse(t.body as string) as Record<string, unknown>;
if ('name' in body || 'topic' in body) {
sublimitHits += 1;
sublimitRequests += 1;
const response = 2 - sublimitHits >= 0 && 10 - sublimitRequests >= 0 ? 200 : 429;
startSublimitIntervals();
if (response === 200) {
return {
statusCode: 200,
data: '',
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
},
},
};
}
return {
statusCode: 429,
data: {
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1000).toString(),
retryAfter: ((retryAfter - Date.now()) / 1000).toString(),
},
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'retry-after': ((retryAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
...responseOptions.headers,
},
},
};
} else if (!('name' in body) && !('topic' in body)) {
sublimitRequests += 1;
const response = 10 - sublimitRequests >= 0 ? 200 : 429;
startSublimitIntervals();
if (response === 200) {
return {
statusCode: 200,
data: '',
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
},
},
};
}
return {
statusCode: 429,
data: {
limit: '10',
remaining: `${10 - sublimitRequests}`,
resetAfter: (sublimitResetAfter / 1000).toString(),
retryAfter: ((sublimitResetAfter - Date.now()) / 1000).toString(),
},
responseOptions: {
headers: {
'x-ratelimit-limit': '10',
'x-ratelimit-remaining': `${10 - sublimitRequests}`,
'x-ratelimit-reset-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
'retry-after': ((sublimitResetAfter - Date.now()) / 1000).toString(),
via: '1.1 google',
...responseOptions.headers,
},
},
};
}
return {
statusCode: 420,
data: 'Oh no',
};
})
.persist();
// Return the current time on these results as their response does not indicate anything
// Queue all requests, don't wait, to allow retroactive check
const [aP, bP, cP, dP, eP] = [
@@ -297,20 +294,60 @@ test('Handle sublimits', async () => {
]); // For additional sublimited checks
const e = await eP;
expect(a).toBeLessThan(b);
expect(b).toBeLessThan(c);
expect(d).toBeLessThan(c);
expect(c).toBeLessThan(e);
expect(d).toBeLessThan(e);
expect(e).toBeLessThan(f);
expect(e).toBeLessThan(g);
expect(g).toBeLessThan(f);
expect(a).toBeLessThanOrEqual(b);
expect(b).toBeLessThanOrEqual(c);
expect(d).toBeLessThanOrEqual(c);
expect(c).toBeLessThanOrEqual(e);
expect(d).toBeLessThanOrEqual(e);
expect(e).toBeLessThanOrEqual(f);
expect(e).toBeLessThanOrEqual(g);
expect(g).toBeLessThanOrEqual(f);
clearInterval(sublimitIntervals.reset);
clearInterval(sublimitIntervals.retry);
clearInterval(sublimitIntervals.reset!);
clearInterval(sublimitIntervals.retry!);
// Reject on RateLimit
const [aP2, bP2, cP2] = [
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
];
await expect(aP2).resolves;
await expect(bP2).rejects.toThrowError();
await expect(bP2).rejects.toBeInstanceOf(RateLimitError);
await expect(cP2).rejects.toThrowError();
await expect(cP2).rejects.toBeInstanceOf(RateLimitError);
});
test('Handle unexpected 429', async () => {
mockPool
.intercept({
path: genPath('/unexpected'),
method: 'GET',
})
.reply(() => {
if (unexpected429) {
unexpected429 = false;
return {
statusCode: 429,
data: '',
responseOptions: {
headers: {
'retry-after': '1',
via: '1.1 google',
},
},
};
}
return {
statusCode: 200,
data: { test: true },
responseOptions,
};
})
.times(3);
const previous = performance.now();
let firstResolvedTime: number;
let secondResolvedTime: number;
@@ -330,33 +367,133 @@ test('Handle unexpected 429', async () => {
});
test('Handle unexpected 429 cloudflare', async () => {
mockPool
.intercept({
path: genPath('/unexpected-cf'),
method: 'GET',
})
.reply(() => {
if (unexpected429cf) {
unexpected429cf = false;
return {
statusCode: 429,
data: '',
responseOptions: {
headers: {
'retry-after': '1',
},
},
};
}
return {
statusCode: 200,
data: { test: true },
responseOptions,
};
})
.times(2); // twice because it re-runs the request after first 429
const previous = Date.now();
expect(await api.get('/unexpected-cf')).toStrictEqual({ test: true });
expect(Date.now()).toBeGreaterThanOrEqual(previous + 1000);
});
test('Handle global rate limits', async () => {
mockPool
.intercept({
path: genPath('/triggerGlobal'),
method: 'GET',
})
.reply(() => ({
data: { global: true },
statusCode: 200,
responseOptions,
}));
mockPool
.intercept({
path: genPath('/regularRequest'),
method: 'GET',
})
.reply(() => ({
data: { test: true },
statusCode: 200,
responseOptions,
}));
expect(await api.get('/triggerGlobal')).toStrictEqual({ global: true });
expect(await api.get('/regularRequest')).toStrictEqual({ test: true });
});
test('Handle temp server outage', async () => {
mockPool
.intercept({
path: genPath('/temp'),
method: 'GET',
})
.reply(() => {
if (serverOutage) {
serverOutage = false;
return {
statusCode: 500,
data: '',
};
}
return {
statusCode: 200,
data: { test: true },
responseOptions,
};
})
.times(2);
expect(await api.get('/temp')).toStrictEqual({ test: true });
});
test('perm server outage', async () => {
mockPool
.intercept({
path: genPath('/outage'),
method: 'GET',
})
.reply(500, '', responseOptions)
.times(4);
const promise = api.get('/outage');
await expect(promise).rejects.toThrowError();
await expect(promise).rejects.toBeInstanceOf(HTTPError);
});
test('server responding too slow', async () => {
const promise = api.get('/slow');
await expect(promise).rejects.toThrowError('The user aborted a request.');
}, 10000);
const api2 = new REST({ timeout: 1 }).setToken('A-Very-Really-Real-Token');
test('Bad Request', async () => {
const promise = api.get('/badRequest');
await expect(promise).rejects.toThrowError('Missing Permissions');
await expect(promise).rejects.toBeInstanceOf(DiscordAPIError);
});
mockPool
.intercept({
path: genPath('/slow'),
method: 'GET',
})
.reply(200, '')
.delay(100)
.times(10);
const promise = api2.get('/slow');
await expect(promise).rejects.toThrowError('Request aborted');
}, 1000);
test('Unauthorized', async () => {
mockPool
.intercept({
path: genPath('/unauthorized'),
method: 'GET',
})
.reply(401, { message: '401: Unauthorized', code: 0 }, responseOptions)
.times(2);
const setTokenSpy = jest.spyOn(invalidAuthApi.requestManager, 'setToken');
// Ensure authless requests don't reset the token
@@ -372,19 +509,32 @@ test('Unauthorized', async () => {
expect(setTokenSpy).toHaveBeenCalledTimes(1);
});
test('Reject on RateLimit', async () => {
const [aP, bP, cP] = [
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
rateLimitErrorApi.patch('/channels/:id', sublimit),
];
await expect(aP).resolves;
await expect(bP).rejects.toThrowError();
await expect(bP).rejects.toBeInstanceOf(RateLimitError);
await expect(cP).rejects.toThrowError();
await expect(cP).rejects.toBeInstanceOf(RateLimitError);
test('Bad Request', async () => {
mockPool
.intercept({
path: genPath('/badRequest'),
method: 'GET',
})
.reply(403, { message: 'Missing Permissions', code: 50013 }, responseOptions);
const promise = api.get('/badRequest');
await expect(promise).rejects.toThrowError('Missing Permissions');
await expect(promise).rejects.toBeInstanceOf(DiscordAPIError);
});
test('malformedRequest', async () => {
expect(await api.get('/malformedRequest')).toBe(null);
// This test doesn't really make sense because
// there is no such thing as a 601 status code.
// So, what exactly is a malformed request?
mockPool
.intercept({
path: genPath('/malformedRequest'),
method: 'GET',
})
.reply(() => ({
statusCode: 405,
data: '',
}));
await expect(api.get('/malformedRequest')).rejects.toBeInstanceOf(DiscordAPIError);
});

View File

@@ -1,11 +1,33 @@
import nock from 'nock';
import { DefaultRestOptions, REST } from '../src';
import { MockAgent, setGlobalDispatcher } from 'undici';
import { Interceptable } from 'undici/types/mock-interceptor';
import { genPath } from './util';
import { REST } from '../src';
const api = new REST();
nock(`${DefaultRestOptions.api}/v${DefaultRestOptions.version}`).get('/simpleGet').reply(200, { test: true });
let mockAgent: MockAgent;
let mockPool: Interceptable;
beforeEach(() => {
mockAgent = new MockAgent();
mockAgent.disableNetConnect();
setGlobalDispatcher(mockAgent);
mockPool = mockAgent.get('https://discord.com');
});
afterEach(async () => {
await mockAgent.close();
});
test('no token', async () => {
mockPool
.intercept({
path: genPath('/simpleGet'),
method: 'GET',
})
.reply(200, 'Well this is awkward...');
const promise = api.get('/simpleGet');
await expect(promise).rejects.toThrowError('Expected token to be set for this request, but none was present');
await expect(promise).rejects.toBeInstanceOf(Error);

View File

@@ -0,0 +1,66 @@
import { Blob } from 'node:buffer';
import { resolveBody, parseHeader } from '../src/lib/utils/utils';
test('GIVEN string parseHeader returns string', () => {
const header = 'application/json';
expect(parseHeader(header)).toBe(header);
});
test('GIVEN string[] parseHeader returns string', () => {
const header = ['application/json', 'wait sorry I meant text/html'];
expect(parseHeader(header)).toBe(header.join(';'));
});
test('GIVEN undefined parseHeader return undefined', () => {
expect(parseHeader(undefined)).toBeUndefined();
});
test('resolveBody', async () => {
await expect(resolveBody(null)).resolves.toBe(null);
await expect(resolveBody(undefined)).resolves.toBe(null);
await expect(resolveBody('Hello')).resolves.toBe('Hello');
await expect(resolveBody(new Uint8Array([1, 2, 3]))).resolves.toStrictEqual(new Uint8Array([1, 2, 3]));
// ArrayBuffers gets resolved to Uint8Array
await expect(resolveBody(new ArrayBuffer(8))).resolves.toStrictEqual(new Uint8Array(new ArrayBuffer(8)));
const urlSearchParams = new URLSearchParams([['a', 'b']]);
await expect(resolveBody(urlSearchParams)).resolves.toBe(urlSearchParams.toString());
const dataView = new DataView(new ArrayBuffer(8));
await expect(resolveBody(dataView)).resolves.toStrictEqual(new Uint8Array(new ArrayBuffer(8)));
const blob = new Blob(['hello']);
await expect(resolveBody(blob)).resolves.toStrictEqual(new Uint8Array(await blob.arrayBuffer()));
const iterable: Iterable<Uint8Array> = {
*[Symbol.iterator]() {
for (let i = 0; i < 3; i++) {
yield new Uint8Array([1, 2, 3]);
}
},
};
await expect(resolveBody(iterable)).resolves.toStrictEqual(new Uint8Array([1, 2, 3, 1, 2, 3, 1, 2, 3]));
const asyncIterable: AsyncIterable<Uint8Array> = {
[Symbol.asyncIterator]() {
let i = 0;
return {
next() {
if (i < 3) {
i++;
return Promise.resolve({ value: new Uint8Array([1, 2, 3]), done: false });
}
return Promise.resolve({ value: undefined, done: true });
},
};
},
};
await expect(resolveBody(asyncIterable)).resolves.toStrictEqual(Buffer.from([1, 2, 3, 1, 2, 3, 1, 2, 3]));
// unknown type
// @ts-expect-error This test is ensuring that this throws
await expect(resolveBody(true)).rejects.toThrow(TypeError);
});

View File

@@ -1,7 +1,7 @@
import { DefaultRestOptions } from '../src';
export function genPath(path: string) {
return `/api/v${DefaultRestOptions.version}${path}`;
export function genPath(path: `/${string}`) {
return `/api/v${DefaultRestOptions.version}${path}` as const;
}
export function jsonHeaders(headers: Record<string, string> = {}) {