@data-client/rest
Version:
Quickly define typed REST resources and endpoints
1,192 lines (1,111 loc) • 34.6 kB
text/typescript
import { Entity, PolymorphicInterface, schema } from '@data-client/endpoint';
import type { DefaultArgs } from '@data-client/endpoint';
import {
CacheProvider,
useCache,
useController,
Controller,
useSuspense,
useQuery,
} from '@data-client/react';
import { makeRenderDataClient, act } from '@data-client/test';
import nock, { ReplyHeaders } from 'nock';
import resource from '../resource';
import RestEndpoint, { RestGenerics } from '../RestEndpoint';
describe('resource()', () => {
const renderDataClient: ReturnType<typeof makeRenderDataClient> =
makeRenderDataClient(CacheProvider);
let mynock: nock.Scope;
class User extends Entity {
readonly id: number | undefined = undefined;
readonly username: string = '';
readonly email: string = '';
readonly isAdmin: boolean = false;
pk() {
return this.id?.toString();
}
}
class MyEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
parseResponse(response: Response): Promise<any> {
return super.parseResponse(response);
}
getRequestInit(body: any) {
if (typeof body === 'object') {
return super.getRequestInit({ ...body, email: 'always@always.com' });
}
return super.getRequestInit(body);
}
additional = 5;
}
const UserResource = resource({
path: 'http\\://test.com/groups/:group/users/:id',
schema: User,
Endpoint: MyEndpoint,
});
const userPayload = {
id: 5,
username: 'ntucker',
email: 'bob@vila.com',
isAdmin: true,
};
beforeEach(() => {
nock(/.*/)
.persist()
.defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
})
.get(`/groups/five/users/${userPayload.id}`)
.reply(200, userPayload)
.get(`/groups/five/users`)
.reply(200, [userPayload])
.options(/.*/)
.reply(200);
mynock = nock(/.*/).defaultReplyHeaders({
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
});
});
afterEach(() => {
nock.cleanAll();
});
it('can override endpoint options', async () => {
const UserResourceBase = resource({
path: 'http\\://test.com/groups/:group/users/:id',
schema: User,
});
// @ts-expect-error
UserResourceBase.getList.getPage.paginationField;
const UserResource = UserResourceBase.extend('getList', {
path: ':blob',
searchParams: {} as { isAdmin?: boolean },
paginationField: 'cursor',
getOptimisticResponse(snap, params) {
params.isAdmin;
params.blob;
// @ts-expect-error
params.nothere;
return [] as User[];
},
process(users: User[]) {
if (!Array.isArray(users)) return users;
return users.slice(0, 7);
},
})
.extend('partialUpdate', {
getOptimisticResponse(snap, params, body) {
params.id;
params.group;
// @ts-expect-error
params.nothere;
return {
id: params.id,
...body,
};
},
})
.extend('delete', {
getOptimisticResponse(snap, params) {
return params;
},
})
.extend('justget', {})
.extend('current', {
path: '/current',
searchParams: {} as { isAdmin?: boolean },
})
.extend('toggleAdmin', {
path: '/toggle/:id',
method: 'POST',
body: undefined,
});
() => UserResource.getList({ blob: '5', isAdmin: true });
() =>
UserResource.getList.getPage({
blob: '5',
isAdmin: true,
cursor: 'next',
});
() => UserResource.getList.getPage({ blob: '5', cursor: 'next' });
() =>
// @ts-expect-error
UserResource.getList.getPage({ blob: '5', isAdmin: true });
() =>
// @ts-expect-error
UserResource.getList.getPage({
cursor: 'next',
isAdmin: true,
});
() => UserResource.get({ group: '1', id: '5' });
() => UserResource.getList.push({ blob: '5' }, { username: 'bob' });
() =>
UserResource.getList.push(
{ blob: '5', isAdmin: true },
{ username: 'bob' },
);
() =>
UserResource.getList.push(
{ blob: '5', isAdmin: false },
{ username: 'bob' },
);
// @ts-expect-error
() => UserResource.getList.push({ group: 'bob' }, { username: 'bob' });
() => UserResource.get({ id: 'hi', group: 'group' });
() => UserResource.justget({ group: 'blob', id: '5' });
// @ts-expect-error
() => UserResource.justget({ id: '5' });
() => UserResource.current();
() => UserResource.current({ isAdmin: true });
() => UserResource.toggleAdmin({ id: '5' });
() => {
// @ts-expect-error - POST should make this have sideEffect true
const DONOTUSE: false = UserResource.toggleAdmin.sideEffect;
};
// @ts-expect-error
() => UserResource.justget({ id: '5' });
mynock.get(`/current`).reply(200, {
id: 5,
username: 'bob',
email: 'bob@bob.com',
isAdmin: false,
});
const { result, waitForNextUpdate, controller } = renderDataClient(() => {
return useSuspense(UserResource.current);
});
await waitForNextUpdate();
expect(result.current.email).toBe('bob@bob.com');
// @ts-expect-error
expect(result.current.notexist).toBeUndefined();
mynock.post(`/5`).reply(200, (uri, body) => ({
id: 10,
username: 'bob',
email: 'bob@bob.com',
...(body as any),
}));
const user = await controller.fetch(
UserResource.getList.push,
{ blob: '5' },
{ username: 'newbob' },
);
expect(user.username).toBe('newbob');
expect(user).toBeInstanceOf(User);
expect(user.isAdmin).toBe(false);
// check types
() => controller.getResponse(UserResource.current, controller.getState());
});
it('can override endpoint options', async () => {
const UserResourceBase = resource({
path: 'http\\://test.com/groups/:group/users/:id',
schema: User,
paginationField: 'cursor',
});
const UserResource = UserResourceBase.extend({
getList: {
path: ':blob',
searchParams: {} as { isAdmin?: boolean },
getOptimisticResponse(snap, params) {
params.isAdmin;
params.blob;
// @ts-expect-error
params.nothere;
return [] as User[];
},
/*process(users: User[]) {
return users.slice(0, 7);
}, TODO: why doesn't this work?*/
},
partialUpdate: {
getOptimisticResponse(snap, params, body) {
params.id;
params.group;
// @ts-expect-error
params.nothere;
return {
id: params.id,
...body,
};
},
},
delete: {
getOptimisticResponse(snap, params) {
return params;
},
},
})
.extend('justget', {})
.extend('current', {
path: '/current',
searchParams: {} as { isAdmin?: boolean },
})
.extend('toggleAdmin', {
path: '/toggle/:id',
method: 'POST',
body: undefined,
getOptimisticResponse(snap, params) {
params.id;
// @ts-expect-error
params.group;
return {
id: params.id,
};
},
});
() => UserResource.getList({ blob: '5', isAdmin: true });
() =>
UserResource.getList.getPage({
blob: '5',
isAdmin: true,
cursor: 'next',
});
() => UserResource.getList.getPage({ blob: '5', cursor: 'next' });
// @ts-expect-error
() => UserResource.getList.getPage({ blob: '5', isAdmin: true });
// @ts-expect-error
() => UserResource.getList.getPage({ cursor: 'next', isAdmin: true });
() => UserResource.get({ group: '1', id: '5' });
() => UserResource.getList.push({ blob: '5' }, { username: 'bob' });
() =>
UserResource.getList.push(
{ blob: '5', isAdmin: true },
{ username: 'bob' },
);
() =>
UserResource.getList.push(
{ blob: '5', isAdmin: false },
{ username: 'bob' },
);
// @ts-expect-error
() => UserResource.getList.push({ group: 'bob' }, { username: 'bob' });
() => UserResource.get({ id: 'hi', group: 'group' });
() => UserResource.justget({ group: 'blob', id: '5' });
// @ts-expect-error
() => UserResource.justget({ id: '5' });
() => UserResource.current();
() => UserResource.current({ isAdmin: true });
() => UserResource.toggleAdmin({ id: '5' });
() => {
// @ts-expect-error - POST should make this have sideEffect true
const DONOTUSE: false = UserResource.toggleAdmin.sideEffect;
};
// @ts-expect-error
() => UserResource.justget({ id: '5' });
mynock.get(`/current`).reply(200, {
id: 5,
username: 'bob',
email: 'bob@bob.com',
isAdmin: false,
});
mynock.get(`/5?isAdmin=false`).reply(200, [
{
id: 5,
username: 'bob',
email: 'bob@bob.com',
isAdmin: false,
},
]);
const { result, waitForNextUpdate, controller } = renderDataClient(() => {
return [
useSuspense(UserResource.current),
useSuspense(UserResource.getList, { blob: '5', isAdmin: false }),
] as const;
});
await waitForNextUpdate();
expect(result.current[1].length).toBe(1);
expect(result.current[0].email).toBe('bob@bob.com');
// @ts-expect-error
expect(result.current[0].notexist).toBeUndefined();
mynock.post(`/5?isAdmin=false`).reply(201, (uri, body) => ({
id: 10,
username: 'bob',
email: 'newbob@bob.com',
...(body as any),
}));
await act(async () => {
const user = await controller.fetch(
UserResource.getList.push,
{ blob: '5', isAdmin: false },
{ username: 'newbob' },
);
expect(user.username).toBe('newbob');
expect(user).toBeInstanceOf(User);
expect(user.isAdmin).toBe(false);
});
expect(result.current[1].length).toBe(2);
expect(result.current[1][1].username).toBe('newbob');
});
it('can override with no generics', async () => {
const UserResource = resource({
path: 'http\\://test.com/groups/:group/users/:id',
schema: User,
paginationField: 'cursor',
}).extend({
getList: {
dataExpiryLength: 10 * 60 * 1000,
},
});
const a: undefined = UserResource.getList.sideEffect;
// @ts-expect-error
const b: true = UserResource.getList.sideEffect;
() => useSuspense(UserResource.getList, { group: 'hi' });
});
it('can override resource endpoints (function form)', async () => {
const UserResource = resource({
path: 'http\\://test.com/groups/:group/users/:id',
schema: User,
paginationField: 'cursor',
}).extend(resourceBase => ({
getList: resourceBase.getList.extend({
path: ':blob',
searchParams: {} as { isAdmin?: boolean },
getOptimisticResponse(snap, params) {
params.isAdmin;
params.blob;
// @ts-expect-error
params.nothere;
return [] as User[];
},
process(users: User[]) {
if (!Array.isArray(users)) return users;
return users.slice(0, 7);
},
}),
partialUpdate: resourceBase.partialUpdate.extend({
getOptimisticResponse(snap, params, body) {
params.id;
params.group;
// @ts-expect-error
params.nothere;
return {
id: params.id,
...body,
};
},
}),
delete: resourceBase.delete.extend({
getOptimisticResponse(snap, params) {
return params;
},
}),
justget: resourceBase.get,
current: resourceBase.get.extend({
path: '/current',
searchParams: {} as { isAdmin?: boolean },
}),
toggleAdmin: resourceBase.get.extend({
path: '/toggle/:id',
method: 'POST',
body: undefined,
getOptimisticResponse(snap, params) {
params.id;
// @ts-expect-error
params.group;
return {
id: params.id,
};
},
}),
}));
() => UserResource.getList({ blob: '5', isAdmin: true });
() =>
UserResource.getList.getPage({
blob: '5',
isAdmin: true,
cursor: 'next',
});
() => UserResource.getList.getPage({ blob: '5', cursor: 'next' });
// @ts-expect-error
() => UserResource.getList.getPage({ blob: '5', isAdmin: true });
// @ts-expect-error
() => UserResource.getList.getPage({ cursor: 'next', isAdmin: true });
() => UserResource.get({ group: '1', id: '5' });
() => UserResource.getList.unshift({ blob: '5' }, { username: 'bob' });
() =>
UserResource.getList.push(
{ blob: '5', isAdmin: true },
{ username: 'bob' },
);
() =>
UserResource.getList.push(
{ blob: '5', isAdmin: false },
{ username: 'bob' },
);
// @ts-expect-error
() => UserResource.getList.push({ group: 'bob' }, { username: 'bob' });
() => UserResource.get({ id: 'hi', group: 'group' });
() => UserResource.justget({ group: 'blob', id: '5' });
// @ts-expect-error
() => UserResource.justget({ id: '5' });
() => UserResource.current();
() => UserResource.current({ isAdmin: true });
() => UserResource.toggleAdmin({ id: '5' });
() => {
// @ts-expect-error - POST should make this have sideEffect true
const DONOTUSE: false = UserResource.toggleAdmin.sideEffect;
};
// @ts-expect-error
() => UserResource.justget({ id: '5' });
mynock.get(`/current`).reply(200, {
id: 5,
username: 'bob',
email: 'bob@bob.com',
isAdmin: false,
});
mynock.get(`/5?isAdmin=false`).reply(200, [
{
id: 5,
username: 'bob',
email: 'bob@bob.com',
isAdmin: false,
},
]);
const { result, waitForNextUpdate, controller } = renderDataClient(() => {
return [
useSuspense(UserResource.current),
useSuspense(UserResource.getList, { blob: '5', isAdmin: false }),
] as const;
});
await waitForNextUpdate();
expect(result.current[1].length).toBe(1);
expect(result.current[0].email).toBe('bob@bob.com');
// @ts-expect-error
expect(result.current[0].notexist).toBeUndefined();
mynock.post(`/5?isAdmin=false`).reply(201, (uri, body) => ({
id: 10,
username: 'bob',
email: 'newbob@bob.com',
...(body as any),
}));
await act(async () => {
const user = await controller.fetch(
UserResource.getList.push,
{ blob: '5', isAdmin: false },
{ username: 'newbob' },
);
expect(user.username).toBe('newbob');
expect(user).toBeInstanceOf(User);
expect(user.isAdmin).toBe(false);
});
expect(result.current[1].length).toBe(2);
expect(result.current[1][1].username).toBe('newbob');
});
it('should not allow paths without at least one argument', () => {
class Todo extends Entity {
id = '';
userId = 0;
title = '';
completed = false;
static key = 'Todo';
pk() {
return this.id;
}
}
expect(() =>
resource({
// TODO(see path types): @ts-expect-error
path: '/todos/',
schema: Todo,
}),
).toThrowErrorMatchingSnapshot();
});
it('UserResource.get should work', async () => {
const { result, waitForNextUpdate } = renderDataClient(() => {
return useSuspense(UserResource.get, { group: 'five', id: '5' });
});
await waitForNextUpdate();
expect(result.current).toEqual(User.fromJS(userPayload));
result.current.isAdmin;
//@ts-expect-error
expect(result.current.notaMember).toBeUndefined();
// @ts-expect-error
() => useSuspense(UserResource.get, { id: '5' });
// @ts-expect-error
() => useSuspense(UserResource.get, { group: 'five' });
});
it('UserResource.getList should work', async () => {
const { result, waitForNextUpdate } = renderDataClient(() => {
return useSuspense(UserResource.getList, { group: 'five' });
});
await waitForNextUpdate();
expect(result.current[0]).toEqual(User.fromJS(userPayload));
result.current[0].isAdmin;
//@ts-expect-error
expect(result.current[0].notaMember).toBeUndefined();
type A = Parameters<typeof UserResource.getList>;
// @ts-expect-error
() => useSuspense(UserResource.getList, { id: '5' });
// @ts-expect-error
() => useSuspense(UserResource.getList, {});
// @ts-expect-error
() => useSuspense(UserResource.getList);
});
it('UserResource.update should work', async () => {
mynock
.put(`/groups/five/users/${userPayload.id}`)
.reply(200, (uri, body: any) => ({
...userPayload,
...body,
}));
const { result, waitForNextUpdate } = renderDataClient(
() => {
return [
useSuspense(UserResource.get, { group: 'five', id: '5' }),
useController(),
] as const;
},
{
initialFixtures: [
{
endpoint: UserResource.get,
args: [{ group: 'five', id: '5' }],
response: userPayload,
},
],
},
);
// eslint-disable-next-line prefer-const
let [user, controller] = result.current;
expect(user.username).toBe(userPayload.username);
await act(() => {
controller.fetch(
UserResource.update,
{ group: 'five', id: '5' },
{ username: 'never' },
);
});
await waitForNextUpdate();
[user] = result.current;
expect(user.username).toBe('never');
() =>
controller.fetch(
UserResource.update,
{ group: 'five', id: '5' },
{ username: 'never' },
// @ts-expect-error
{ username: 'never' },
);
// @ts-expect-error
() => controller.fetch(UserResource.update, { username: 'never' });
// @ts-expect-error
() => controller.fetch(UserResource.update, 1, 'hi');
() =>
controller.fetch(
UserResource.update,
{ group: 'five', id: '5' },
// @ts-expect-error
{ sdf: 'never' },
);
() =>
controller.fetch(
UserResource.update,
// @ts-expect-error
{ sdf: 'five', id: '5' },
{ username: 'never' },
);
});
it('UserResource.getList.push should work', async () => {
mynock.post(`/groups/five/users`).reply(200, (uri, body: any) => ({
id: 5,
...body,
}));
const { result } = renderDataClient(() => {
return [
useCache(UserResource.get, { group: 'five', id: '5' }),
useController(),
] as const;
});
// eslint-disable-next-line prefer-const
let [_, controller] = result.current;
await act(async () => {
await controller.fetch(
UserResource.getList.push,
{ group: 'five' },
{
username: 'createduser',
email: 'haha@gmail.com',
},
);
});
const user = result.current[0];
expect(user).toBeDefined();
expect(user?.username).toBe('createduser');
// our custom endpoint ensures this
expect(user?.email).toBe('always@always.com');
() =>
controller.fetch(
UserResource.getList.push,
// @ts-expect-error
{ id: 'five' },
{ username: 'never' },
);
// @ts-expect-error
() => controller.fetch(UserResource.getList.push, { username: 'never' });
// @ts-expect-error
() => controller.fetch(UserResource.getList.push, 1, 'hi');
() =>
controller.fetch(
UserResource.getList.push,
{ group: 'five' },
// @ts-expect-error
{ sdf: 'never' },
);
() =>
controller.fetch(
UserResource.getList.push,
// @ts-expect-error
{ sdf: 'five' },
{ username: 'never' },
);
});
it.each([
{
response: {
id: userPayload.id,
},
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json',
} as ReplyHeaders,
},
{
response: '',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'text',
} as ReplyHeaders,
},
])(
'UserResource.delete should work with $response',
async ({ response, headers }) => {
mynock
.delete(`/groups/five/users/${userPayload.id}`)
.reply(200, (uri, body: any) => response, headers);
const { result, waitForNextUpdate } = renderDataClient(
() => {
return [
useSuspense(UserResource.get, { group: 'five', id: '5' }),
useController(),
] as const;
},
{
initialFixtures: [
{
endpoint: UserResource.get,
args: [{ group: 'five', id: '5' }],
response: userPayload,
},
],
resolverFixtures: [
{
endpoint: UserResource.get,
args: [{ group: 'five', id: '5' }],
response: 'not found',
error: true,
},
],
},
);
// eslint-disable-next-line prefer-const
let [user, controller] = result.current;
expect(user.username).toBe(userPayload.username);
await act(() => {
controller.fetch(UserResource.delete, { group: 'five', id: '5' });
});
await waitForNextUpdate();
// this means we suspended; so it hit the resolver fixture
expect(result.error).toMatchSnapshot();
() =>
controller.fetch(
UserResource.delete,
{ group: 'five', id: '5' },
// @ts-expect-error
{ username: 'never' },
);
// @ts-expect-error
() => controller.fetch(UserResource.delete);
// @ts-expect-error
() => controller.fetch(UserResource.delete, 1);
() =>
controller.fetch(
UserResource.delete,
// @ts-expect-error
{ sdf: 'never' },
);
},
);
it('should allow complete overrides', async () => {
mynock
.get(`/groups/vi/users/5`)
.reply(200, { id: 5, title: 'hi', username2: 'bob' });
class User2 extends Entity {
readonly id: number | undefined = undefined;
readonly username2: string = '';
readonly email: string = '';
readonly isAdmin: boolean = false;
pk() {
return this.id?.toString();
}
}
const UserResourceExtend = {
...UserResource,
get: UserResource.get.extend({
path: 'http\\://test.com/groups/:magic/users/:id',
schema: User2,
}),
};
const { result, waitForNextUpdate } = renderDataClient(() => {
return useSuspense(UserResourceExtend.get, { magic: 'vi', id: 5 });
});
await waitForNextUpdate();
expect(result.current.username2).toBe('bob');
// @ts-expect-error
expect(result.current.username).toBeUndefined();
});
describe('unions', () => {
const feedPayload = {
id: '5',
title: 'my first feed',
type: 'link' as const,
url: 'https://true.io',
};
class Feed extends Entity {
readonly id: string = '';
readonly title: string = '';
readonly type: 'link' | 'post' = 'post';
pk() {
return this.id;
}
}
class FeedLink extends Feed {
readonly url: string = '';
readonly type = 'link' as const;
}
class FeedPost extends Feed {
readonly content: string = '';
readonly type = 'post' as const;
}
const FeedUnion = new schema.Union(
{ post: FeedPost, link: FeedLink },
'type',
);
const FeedResource = resource({
path: 'http\\://test.com/feed/:id',
schema: FeedUnion,
Endpoint: MyEndpoint,
});
it('should work with detail', async () => {
mynock.get(`/feed/${feedPayload.id}`).reply(200, feedPayload);
const { result, waitForNextUpdate } = renderDataClient(() => {
return useSuspense(FeedResource.get, { id: '5' });
});
await waitForNextUpdate();
const feed = result.current;
if (feed.type === 'link') {
expect(feed.url).toBe(feedPayload.url);
// @ts-expect-error
expect(feed.content).toBeUndefined();
} else {
// this branch doesn't run - just a type test
feed.content;
// @ts-expect-error
expect(feed.url).toBeUndefined();
}
// another type test
// @ts-expect-error
() => useSuspense(FeedResource.get, { sdf: '5' });
// @ts-expect-error
() => FeedResource.get({ id: '5', sdf: '5' });
// @ts-expect-error
() => FeedResource.get({ id: '5' }, 5);
});
it('should work with list [no args]', async () => {
mynock.get(`/feed`).reply(200, [feedPayload]);
const { result, waitForNextUpdate } = renderDataClient(() => {
return useSuspense(FeedResource.getList);
});
await waitForNextUpdate();
const feed = result.current[0];
if (feed.type === 'link') {
expect(feed.url).toBe(feedPayload.url);
// @ts-expect-error
expect(feed.content).toBeUndefined();
} else {
// this branch doesn't run - just a type test
feed.content;
// @ts-expect-error
expect(feed.url).toBeUndefined();
}
// another type test
// @ts-expect-error
() => useSuspense(FeedResource.getList, 5);
// @ts-expect-error
() => FeedResource.getList({ id: '5' }, 5);
});
it('should work with getList.push [no args]', async () => {
mynock.post(`/feed`).reply(200, (uri, body: any) => ({
id: 5,
...body,
}));
const { result, waitForNextUpdate } = renderDataClient(() => {
return [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore users should useQuery() for this now.
// Tho there is an argument for useSuspense() being able to pre-use it
useCache(FeedResource.get, { id: '5', type: 'link' }),
useController(),
useQuery(FeedUnion, { id: '5', type: 'link' }),
] as const;
});
// eslint-disable-next-line prefer-const
let [_, controller] = result.current;
await act(() => {
controller.fetch(FeedResource.getList.push, feedPayload);
});
await waitForNextUpdate();
const feed = result.current[0];
expect(feed).toBeDefined();
if (!feed) throw new Error('never');
if (feed.type === 'link') {
expect(feed.url).toBe(feedPayload.url);
// @ts-expect-error
expect(feed.content).toBeUndefined();
}
expect(feed).toBe(result.current[2]);
() => {
// @ts-expect-error
useQuery(FeedUnion, { id: '5', typed: 'link' });
// @ts-expect-error
useQuery(FeedUnion, { id: '5' });
// @ts-expect-error
useQuery(FeedUnion, { id: '5', type: 'bob' });
};
() =>
controller.fetch(
UserResource.create,
// @ts-expect-error
{ id: 'five' },
{ username: 'never' },
);
// @ts-expect-error
() => controller.fetch(UserResource.getList.push, { username: 'never' });
// @ts-expect-error
() => controller.fetch(UserResource.getList.push, 1, 'hi');
() =>
controller.fetch(
UserResource.getList.push,
{ group: 'five' },
// @ts-expect-error
{ sdf: 'never' },
);
() =>
controller.fetch(
UserResource.getList.push,
// @ts-expect-error
{ sdf: 'five' },
{ username: 'never' },
);
});
});
it('UserResource.getList.push.extends() should work', async () => {
interface CreateDeviceBody {
username: string;
}
interface UserInterface {
readonly id: number | undefined;
readonly username: string;
readonly email: string;
readonly isAdmin: boolean;
}
const createUser = UserResource.getList.push.extend({
update: (newId, params) => {
return {
[UserResource.getList.key({ group: params.group })]: (
prevResponse = { items: [] },
) => ({
items: [...prevResponse.items, newId],
}),
};
},
//searchParams: undefined as any,
body: {} as CreateDeviceBody,
schema: User,
sideEffect: true,
process(...args: any) {
return UserResource.getList.push.process.apply(
this,
args,
) as UserInterface;
},
});
const ctrl = new Controller();
() => ctrl.fetch(createUser, { group: 'hi' }, { username: 'bob' });
() => createUser({ group: 'hi' }, { username: 'bob' });
// @ts-expect-error
() => createUser({ group: 'hi', id: 'what' }, { username: 'bob' });
// @ts-expect-error
() => createUser({ group: 'hi' });
// @ts-expect-error
() => createUser.url({ group: 'hi', id: 'what' }, { username: 'bob' });
expect(createUser.url({ group: 'hi' }, {} as any)).toMatchInlineSnapshot(
`"http://test.com/groups/hi/users"`,
);
});
it('UserResource.getList.push.extends() should work with zero urlParams', async () => {
const UserResource = resource({
path: 'http\\://test.com/users/:id',
schema: User,
Endpoint: MyEndpoint,
});
interface CreateDeviceBody {
username: string;
}
interface UserInterface {
readonly id: number | undefined;
readonly username: string;
readonly email: string;
readonly isAdmin: boolean;
}
const createUser = UserResource.getList.push.extend({
update: newId => {
return {
[UserResource.getList.key()]: (prevResponse = { items: [] }) => ({
items: [...prevResponse.items, newId],
}),
};
},
//searchParams: undefined as any,
body: {} as CreateDeviceBody,
schema: User,
sideEffect: true,
process(...args: any) {
return UserResource.getList.push.process.apply(
this,
args,
) as UserInterface;
},
});
const ctrl = new Controller();
() => ctrl.fetch(createUser, { username: 'bob' });
() => createUser({ username: 'bob' });
// @ts-expect-error
() => createUser({ id: 'what' }, { username: 'bob' });
// @ts-expect-error
() => createUser({ id: 'what' });
// @ts-expect-error
() => createUser.url({ id: 'what' }, { username: 'bob' });
expect(createUser.url({} as any)).toMatchInlineSnapshot(
`"http://test.com/users"`,
);
});
it('getList.push should use custom lifecycle methods of getList', async () => {
mynock.post(`/users`).reply(201, (uri, body: any) => ({
...body,
id: 5,
}));
const UserResource = resource({
path: '/users/:id',
schema: User,
optimistic: true,
}).extend(Base => ({
getList: Base.getList.extend({
getRequestInit(body) {
if (body) {
return Base.getList.getRequestInit.call(this, {
id: Math.random(),
isAdmin: true,
...body,
});
}
return Base.getList.getRequestInit.call(this, body);
},
}),
}));
const { controller, result } = renderDataClient(
() => {
return useSuspense(UserResource.getList);
},
{
initialFixtures: [
{
endpoint: UserResource.getList,
args: [],
response: [],
},
],
},
);
await act(async () => {
await controller.fetch(UserResource.getList.push, { username: 'bob' });
});
expect(result.current.length).toBe(1);
// this is set in our override
expect(result.current[0].isAdmin).toBe(true);
});
it('searchParams are used in Queries based on getList.schema', () => {
class Todo extends Entity {
id = '';
readonly userId: number = 0;
readonly title: string = '';
readonly completed: boolean = false;
static key = 'Todo';
pk() {
return this.id;
}
}
const TodoResource = resource({
path: '/todos/:id',
schema: Todo,
optimistic: true,
searchParams: {} as { userId?: string | number } | undefined,
});
const queryRemainingTodos = new schema.Query(
TodoResource.getList.schema,
entries => entries.filter(todo => !todo.completed).length,
);
() => useQuery(queryRemainingTodos, { userId: 1 });
() => useQuery(queryRemainingTodos);
// @ts-expect-error
() => useQuery(queryRemainingTodos, { user: 1 });
// @ts-expect-error
() => useQuery(queryRemainingTodos, 5);
// @ts-expect-error
() => useQuery(queryRemainingTodos, { userId: 1 }, 5);
});
describe('warnings', () => {
let warnSpy: jest.SpyInstance;
afterEach(() => {
warnSpy.mockRestore();
});
beforeEach(() => {
warnSpy = jest.spyOn(global.console, 'warn').mockImplementation(() => {});
});
it('should warn when mis-capitalizing options', () => {
resource({
path: 'http\\://test.com/users/:id',
schema: User,
endpoint: MyEndpoint,
});
expect(warnSpy).toHaveBeenCalled();
expect(warnSpy.mock.calls).toMatchSnapshot();
});
it('should warn when mis-capitalizing options', () => {
class MyCollection<
S extends any[] | PolymorphicInterface = any,
Args extends any[] = DefaultArgs,
Parent = any,
> extends schema.Collection<S, Args, Parent> {
// getList.push should add to Collections regardless of its 'orderBy' argument
// in other words: `orderBy` is a non-filtering argument - it does not influence which results are returned
nonFilterArgumentKeys(key: string) {
return key === 'orderBy';
}
}
resource({
path: 'http\\://test.com/users/:id',
schema: User,
collection: MyCollection,
});
expect(warnSpy).toHaveBeenCalled();
expect(warnSpy.mock.calls).toMatchSnapshot();
});
});
});