@data-client/endpoint
Version:
Declarative Network Interface Definitions
734 lines (651 loc) • 23.6 kB
text/typescript
import nock from 'nock';
import type { default as TEndpoint } from '../endpoint';
import { EndpointInterface } from '../interface';
import Entity from '../schemas/Entity';
describe.each([true, false])(`Endpoint (CSP %s)`, mockCSP => {
jest.resetModules();
jest.mock('../CSP', () => ({ CSP: mockCSP }));
const Endpoint: typeof TEndpoint = require('../endpoint').default;
afterAll(() => {
jest.clearAllMocks();
});
const payload = { id: '5', username: 'bobber' };
const payload2 = { id: '6', username: 'tomm' };
const assetPayload = { symbol: 'btc', price: '5.0' };
const fetchUsers = function (this: any, { id }: { id: string }) {
return fetch(`/${this.root || 'users'}/${id}`).then(res =>
res.json(),
) as Promise<typeof payload>;
};
const fetchUsersIdParam = function (this: any, id: string) {
return fetch(`/${this.root || 'users'}/${id}`).then(res =>
res.json(),
) as Promise<typeof payload>;
};
const fetchUserList = function (this: any) {
return fetch(`/${this.root || 'users'}/`).then(res =>
res.json(),
) as Promise<(typeof payload)[]>;
};
const fetchAsset = ({ symbol }: { symbol: string }) =>
fetch(`/asset/${symbol}`).then(res => res.json()) as Promise<
typeof assetPayload
>;
beforeAll(() => {
nock(/.*/)
.persist()
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.options(/.*/)
.reply(200)
.get(`/users/${payload.id}`)
.reply(200, payload)
.get(`/users/`)
.reply(200, [payload])
.get(`/moreusers/${payload.id}`)
.reply(200, { ...payload, username: 'moreusers' })
.get(`/asset/${assetPayload.symbol}`)
.reply(200, assetPayload);
nock(/.*/, { reqheaders: { Auth: 'password' } })
.persist()
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.options(/.*/)
.reply(200)
.get(`/users/current`)
.reply(200, payload);
nock(/.*/, { reqheaders: { Auth: 'password2' } })
.persist()
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.options(/.*/)
.reply(200)
.get(`/users/current`)
.reply(200, payload2);
});
afterAll(() => {
nock.cleanAll();
});
describe('Function', () => {
let errorSpy: jest.SpyInstance;
afterEach(() => {
errorSpy.mockRestore();
});
beforeEach(
() => (errorSpy = jest.spyOn(console, 'error').mockImplementation()),
);
it('should work when called as function', async () => {
const UserDetail = new Endpoint(fetchUsers);
// check return type and call params
const response = await UserDetail({ id: payload.id });
expect(response).toEqual(payload);
expect(response.username).toBe(payload.username);
// @ts-expect-error
expect(response.notexist).toBeUndefined();
// check additional properties defaults
expect(UserDetail.sideEffect).toBe(undefined);
expect(UserDetail.schema).toBeUndefined();
expect(UserDetail.key({ id: payload.id })).toMatchInlineSnapshot(
`"fetchUsers [{"id":"5"}]"`,
);
// @ts-expect-error
expect(UserDetail.notexist).toBeUndefined();
// @ts-expect-error
const a: 'mutate' = UserDetail.type;
// these are all meant to fail - are typescript tests
await expect(async () => {
// @ts-expect-error
await UserDetail({ id: 5 });
// @ts-expect-error
await UserDetail();
}).rejects.toBeDefined();
});
it('should have a name', () => {
const UserDetail = new Endpoint(fetchUsersIdParam);
expect(UserDetail.name).toBe('fetchUsersIdParam');
const Next = new Endpoint(fetchUsersIdParam, { name: 'specialName' });
expect(Next.name).toBe('specialName');
const Another = Next.extend({ name: 'new' });
expect(Another.name).toBe('new');
const Third = Another.extend({ method: 'POST' }).extend({ extra: 5 });
expect(Third.name).toBe('new');
expect(Third.key('5')).toMatchInlineSnapshot(`"new ["5"]"`);
const Fourth = Third.extend({ fetch: fetchUserList });
expect(Fourth.name).toBe('fetchUserList');
const Weird = new Endpoint(fetchUsersIdParam, { fetch: fetchUserList });
expect(Weird.name).toBe(`fetchUsersIdParam`);
});
it('should console.error with autoname failures', () => {
const UserDetail = new Endpoint(function (this: any, id: string) {
return fetch(`/${this.root || 'users'}/${id}`).then(res =>
res.json(),
) as Promise<typeof payload>;
});
UserDetail.name;
expect(errorSpy.mock.calls.length).toBe(1);
expect(errorSpy.mock.calls).toMatchSnapshot();
});
it('should work when called with string parameter', async () => {
const UserDetail = new Endpoint(fetchUsersIdParam);
// check return type and call params
const response = await UserDetail(payload.id);
expect(response).toEqual(payload);
expect(response.username).toBe(payload.username);
// @ts-expect-error
expect(response.notexist).toBeUndefined();
// check additional properties defaults
expect(UserDetail.sideEffect).toBe(undefined);
expect(UserDetail.schema).toBeUndefined();
expect(UserDetail.key(payload.id)).toMatchInlineSnapshot(
`"fetchUsersIdParam ["5"]"`,
);
// @ts-expect-error
expect(UserDetail.notexist).toBeUndefined();
// @ts-expect-error
const a: 'mutate' = UserDetail.type;
// these are all meant to fail - are typescript tests
await expect(async () => {
// @ts-expect-error
await UserDetail({ id: payload.id });
// @ts-expect-error
UserDetail.key({ id: payload.id });
// @ts-expect-error
await UserDetail(5);
// @ts-expect-error
await UserDetail();
}).rejects.toBeDefined();
});
it('should work when called with zero parameters', async () => {
const UserList = new Endpoint(fetchUserList);
// check return type and call params
const response = await UserList();
expect(response).toEqual([payload]);
expect(response[0].username).toBe(payload.username);
// @ts-expect-error
expect(response.notexist).toBeUndefined();
// check additional properties defaults
expect(UserList.sideEffect).toBe(undefined);
expect(UserList.schema).toBeUndefined();
expect(UserList.key()).toMatchInlineSnapshot(`"fetchUserList []"`);
// @ts-expect-error
expect(UserList.notexist).toBeUndefined();
// @ts-expect-error
const a: 'mutate' = UserList.type;
// these are all meant to fail - are typescript tests
await expect(async () => {
// @ts-expect-error
await UserList({ id: payload.id });
// @ts-expect-error
UserList.key({ id: payload.id });
// @ts-expect-error
await UserList(5);
}).resolves;
});
});
describe('Function.bind', () => {
it('should work when called as function', async () => {
const UserDetail = new Endpoint(fetchUsers, { root: 'moreusers' });
// @ts-expect-error
UserDetail.bind(undefined, { id: { fiver: 5 } });
const boundDetail = UserDetail.bind(undefined, { id: payload.id });
// @ts-expect-error
boundDetail({ id: payload.id });
const response = await boundDetail();
expect(response).toEqual({ ...payload, username: 'moreusers' });
expect(response.username).toBe('moreusers');
// @ts-expect-error
expect(response.notexist).toBeUndefined();
// check additional properties defaults
expect(boundDetail.sideEffect).toBe(undefined);
expect(boundDetail.key()).toMatchInlineSnapshot(`"fetch [{"id":"5"}]"`);
expect(boundDetail.root).toBe('moreusers');
// @ts-expect-error
expect(boundDetail.notexist).toBeUndefined();
});
});
it('should work when extended', async () => {
const BaseFetch = new Endpoint(fetchUsers);
// @ts-expect-error
const aa: true = BaseFetch.sideEffect;
const bb: false = BaseFetch.sideEffect;
const UserDetail = new Endpoint(fetchUsers).extend({
sideEffect: true,
key: ({ id }: { id: string }) => `fetch my user ${id}`,
});
// @ts-expect-error
const a: false = UserDetail.sideEffect;
const b: true = UserDetail.sideEffect;
// ts-expect-error
//const c: undefined = UserDetail.extend({ dataExpiryLength: 5 }).sideEffect;
//const d: true = UserDetail.extend({ dataExpiryLength: 5 }).sideEffect;
function t(a: EndpointInterface<typeof fetchUsers, any, false>) {}
// @ts-expect-error
t(UserDetail);
t(BaseFetch);
expect(UserDetail.key({ id: '500' })).toMatchInlineSnapshot(
`"fetch my user 500"`,
);
// @ts-expect-error
expect(() => UserDetail.key()).toThrow();
// @ts-expect-error
expect(UserDetail.key({ not: 'five' })).toMatchInlineSnapshot(
`"fetch my user undefined"`,
);
// @ts-expect-error
expect(UserDetail.key({ id: 5 })).toMatchInlineSnapshot(
`"fetch my user 5"`,
);
new Endpoint(fetchUsers).extend({
sideEffect: true,
// @ts-expect-error
key: ({ a }: { a: number }) => `fetch my user ${a}`,
});
new Endpoint(fetchUsers).extend({
sideEffect: true,
fetch: fetchAsset,
// TODO: ts-expect-error
key: ({ id }: { id: string }) => `fetch my user ${id}`,
});
const AssetDetail = new Endpoint(fetchUsers).extend({
fetch: fetchAsset,
});
const response = await AssetDetail({ symbol: assetPayload.symbol });
expect(response).toEqual(assetPayload);
expect(response.price).toBe(assetPayload.price);
// @ts-expect-error
expect(response.notexist).toBeUndefined();
expect(AssetDetail.key({ symbol: 'doge' })).toMatchInlineSnapshot(
`"fetchAsset [{"symbol":"doge"}]"`,
);
});
it('should infer return type when schema is specified but fetch function has no typing', async () => {
class User extends Entity {
readonly id: string = '';
readonly username: string = '';
pk() {
return this.id;
}
}
class User2 extends User {
readonly extra: number = 0;
}
const UserDetail = new Endpoint(
({ id }: { id: string }) => fetch(`/users/${id}`).then(res => res.json()),
{ schema: User },
);
const user = await UserDetail({ id: payload.id });
expect(user).toEqual(payload);
expect(user.username).toBe(payload.username);
// extends
const Extended = UserDetail.extend({
schema: User2,
});
const user2 = await Extended({ id: payload.id });
expect(user2).toEqual(payload);
// doesn't actually generate class
expect(user2.extra).toBe(undefined);
});
describe('auth patterns (usage with `this`)', () => {
function fetchAuthd(this: { token: string }): Promise<typeof payload> {
return fetch(`/users/current`, {
headers: { Auth: this.token },
}).then(res => res.json());
}
function key(this: { token: string }) {
return `current user ${this.token}`;
}
it('makes', async () => {
const UserCurrent = new Endpoint(fetchAuthd, { token: 'password', key });
const response = await UserCurrent();
expect(response).toEqual(payload);
expect(response.username).toBe(payload.username);
expect(UserCurrent.key()).toMatchInlineSnapshot(
`"current user password"`,
);
UserCurrent.key = function () {
return Object.getPrototypeOf(UserCurrent).key.call(this) + 'never';
};
});
it('should use provided context in fetch and key', async () => {
const UserCurrent = new Endpoint(fetchAuthd, { token: 'password', key });
const response = await UserCurrent();
expect(response).toEqual(payload);
expect(response.username).toBe(payload.username);
// @ts-expect-error
expect(response.notexist).toBeUndefined();
expect(UserCurrent.key()).toMatchInlineSnapshot(
`"current user password"`,
);
});
it('should typescript error when missing expected this members', () => {
// @ts-expect-error
new Endpoint(fetchAuthd, { key });
// @ts-expect-error
new Endpoint(fetchAuthd, { token: 5, key });
});
function key2(this: { token: number }) {
return `current user ${this.token}`;
}
function key3(this: { token: string }, { id }: { id: string }) {
return `current user ${this.token}`;
}
function key4() {
return `current user`;
}
it('should not allow mismatched key', () => {
// TODO: ts-expect-error new Endpoint(fetchAuthd, { token: 'hi', key: key2 });
// @ts-expect-error
new Endpoint(fetchAuthd, { token: 'hi', key: key3 });
new Endpoint(fetchAuthd, { token: 'hi', key: key4 });
});
it('should not allow mismatched key when extending', () => {
const UserCurrent = new Endpoint(fetchAuthd, { token: 'password', key });
// TODO: ts-expect-error UserCurrent.extend({ key: key2 });
// @ts-expect-error
UserCurrent.extend({ key: key3 });
const a = UserCurrent.extend({ key: key4 });
expect(a.key()).toBe(key4());
});
it('should break when trying to use reserved `this` members', () => {
function fetchAuthd(this: {
token: string;
sideEffect: number;
}): Promise<typeof payload> {
return fetch(`/users/current`, {
headers: { Auth: this.token },
}).then(res => res.json());
}
// @ts-expect-error TODO: make this error message actually readable
new Endpoint(fetchAuthd, {
token: 'password',
key,
sideEffect: 5,
});
});
});
describe('helper members', () => {
it('url helper', async () => {
const url = ({ id }: { id: string }) => `/users/${id}`;
const fetchUsers = function (
this: { url: (params: { id: string }) => string },
{ id }: { id: string },
) {
return fetch(this.url({ id })).then(res => res.json()) as Promise<
typeof payload
>;
};
// @ts-expect-error
new Endpoint(fetchUsers, { url: '', random: 5 });
// @ts-expect-error
new Endpoint(fetchUsers, { url }).extend({ url: 'hi' });
const UserDetail = new Endpoint(
function ({ id }: { id: string }) {
this.random;
// @ts-expect-error
this.notexistant;
return fetch(this.url({ id })).then(res => res.json()) as Promise<
typeof payload
>;
},
{
url,
random: 599,
dataExpiryLength: 5000,
name: 'UserDetai',
},
);
const a: false = UserDetail.sideEffect;
// @ts-expect-error
const b: true = UserDetail.sideEffect;
UserDetail.schema;
UserDetail.random;
// @ts-expect-error
UserDetail.nonexistant;
UserDetail.key({ id: 'hi' });
// @ts-expect-error
() => UserDetail.key({ nonexistant: 5 });
// @ts-expect-error
() => UserDetail.key({ id: 5 });
let res = await UserDetail({ id: payload.id });
expect(res).toEqual(payload);
expect(res.username).toBe(payload.username);
// @ts-expect-error
expect(res.notexist).toBeUndefined();
// test extending parts that aren't used in this
const Extended = UserDetail.extend({ random: 100 });
res = await Extended({ id: payload.id });
expect(res).toEqual(payload);
expect(res.username).toBe(payload.username);
// @ts-expect-error
expect(res.notexist).toBeUndefined();
UserDetail.extend({
url: function (params: { id: string }) {
return this.constructor.prototype.url(params) + '/more';
},
// @ts-expect-error
random: '600',
});
const Test = UserDetail.extend({
random: 600,
});
// check return type and call params
const response = await Test({ id: payload.id });
expect(response).toEqual(payload);
expect(response.username).toBe(payload.username);
// @ts-expect-error
expect(response.notexist).toBeUndefined();
});
it('should work with key', () => {
const url = ({ id }: { id: string }) => `/users/${id}`;
class User extends Entity {
readonly id: string = '';
}
const UserDetail = new Endpoint(
function ({ id }: { id: string }) {
this.schema;
this.random;
// @ts-expect-error
this.notexistant;
return fetch(this.url({ id })).then(res => res.json()) as Promise<
typeof payload
>;
},
{
url,
random: 599,
schema: [User],
key: function (this: any, { id }: { id: string }) {
this.random;
this.schema;
return id + 'hi';
},
},
);
const sch: (typeof User)[] = UserDetail.schema;
const s: false = UserDetail.sideEffect;
UserDetail.random;
// @ts-expect-error
UserDetail.nonexistant;
UserDetail.key({ id: 'hi' });
// @ts-expect-error
() => UserDetail.key({ nonexistant: 5 });
// @ts-expect-error
() => UserDetail.key({ id: 5 });
});
it('testKey should match keys', () => {
const getUsers = new Endpoint(fetchUsers);
const nomatch = getUsers.extend({ name: 'not matching' });
expect(getUsers.testKey(getUsers.key({ id: '5' }))).toBeTruthy();
expect(getUsers.testKey(getUsers.key({ id: '100' }))).toBeTruthy();
expect(getUsers.testKey(getUsers.key({ id: 'xxx?*' }))).toBeTruthy();
expect(getUsers.testKey(nomatch.key({ id: '5' }))).toBeFalsy();
});
});
describe('AbortController', () => {
const url = ({ id }: { id: string }) => `/users/${id}`;
const UserDetail = new Endpoint(
function ({ id }: { id: string }) {
const init: RequestInit = {};
if (this.signal) {
init.signal = this.signal;
}
return fetch(this.url({ id }), init).then(res => res.json()) as Promise<
typeof payload
>;
},
{
url,
signal: undefined as AbortSignal | undefined,
},
);
it('should work without signal', async () => {
const user = await UserDetail({ id: payload.id });
expect(user.username).toBe(payload.username);
});
it('should reject when aborted', async () => {
const abort = new AbortController();
const AbortUser = UserDetail.extend({ signal: abort.signal });
await expect(async () => {
const promise = AbortUser({ id: payload.id });
abort.abort();
return await promise;
}).rejects.toMatchInlineSnapshot(`[AbortError: Aborted]`);
});
});
describe('class', () => {
/*class ResourceEndpoint<
F extends (params?: any, body?: any) => Promise<any>,
S extends Schema | undefined = undefined,
M extends boolean | undefined = undefined
> extends Endpoint<F, S, M> {
constructor(
fetchFunction: F,
options?: EndpointOptions<
(this: ThisParameterType<F>, ...args: Parameters<F>) => string,
S,
M
> &
ThisParameterType<F>,
) {
super(fetchFunction, options);
}
fetch(...args: Parameters<F>) {
return fetch(this.url(args[0]), this.init).then(res => res.json());
}
init: RequestInit = { method: 'GET' };
key(params: { id: string }) {
return `${this.init.method} ${this.url(params)}`;
}
}
const init = this.getFetchInit({ method: 'GET' });
const fetch = this.fetch.bind(this);
return new Endpoint(
function (
this: { url: (p: any) => string; init: RequestInit },
params: Readonly<object>,
) {
return fetch(this.url(params), this.init);
},
{
...this.getEndpointExtra(),
key: function (
this: { url: (p: any) => string; init: RequestInit },
params: Readonly<object>,
) {
return `${this.init.method} ${this.url(params)}`;
},
url: this.url.bind(this),
init,
},
);*/
/* describe('auth patterns', () => {
class AuthEndpoint<
F extends (
this: AuthEndpoint<any, any, any>,
params?: any,
body?: any,
) => Promise<any>,
S extends Schema | undefined = undefined,
M extends boolean | undefined = undefined
> extends Endpoint<F, S, M> {
token = 'password';
authdFetch(info: RequestInfo, init?: RequestInit) {
return fetch(info, {
...init,
headers: { ...init?.headers, Auth: this.token },
});
}
}
function fetchAuthd(
this: AuthEndpoint<any, any, any>,
): Promise<typeof payload> {
return this.authdFetch(`/users/current`).then(res => res.json());
}
it('should use provided context', async () => {
const UserCurrent = new AuthEndpoint(fetchAuthd);
const response = await UserCurrent();
expect(response).toEqual(payload);
expect(response.username).toBe(payload.username);
// @ts-expect-error
expect(response.notexist).toBeUndefined();
expect(UserCurrent.key()).toMatchInlineSnapshot(
`"fetchAuthd undefined"`,
);
});
it('should use extended token', async () => {
//sdf @ts-expect-error
const UserCurrent = new AuthEndpoint(fetchAuthd, {
token: 'password3',
}).extend({
token: 'password2',
sideEffect: true,
});
console.log((UserCurrent as any).token);
const response = await UserCurrent();
expect(response).toEqual(payload2);
expect(response.username).toBe(payload2.username);
// @ts-expect-error
expect(response.notexist).toBeUndefined();
expect(UserCurrent.key()).toMatchInlineSnapshot(
`"fetchAuthd undefined"`,
);
});
});
});*/
/*describe('custom fetch for snakeCase', () => {
function deeplyApplyKeyTransform(
obj: any,
transform: (key: string) => string,
) {
const ret: Record<string, any> = Array.isArray(obj) ? [] : {};
Object.keys(obj).forEach(key => {
if (obj[key] != null && typeof obj[key] === 'object') {
ret[transform(key)] = deeplyApplyKeyTransform(obj[key], transform);
} else {
ret[transform(key)] = obj[key];
}
});
return ret;
}
async function fetch(input: RequestInfo, init: RequestInit) {
// we'll need to do the inverse operation when sending data back to the server
if (init.body) {
init.body = deeplyApplyKeyTransform(init.body, snakeCase) as any;
}
// perform actual network request getting back json
const jsonResponse: object = await fetch(input, init);
// do the conversion!
return deeplyApplyKeyTransform(jsonResponse, camelCase);
}
const BaseEndpoint = new Endpoint(fetch);
it('should extends', () => {
BaseEndpoint.extend({ fetch: })
})*/
});
});