446 lines
13 KiB
TypeScript
446 lines
13 KiB
TypeScript
|
import { makeDataProvider, defaultAppend } from './generic-data-provider';
|
||
|
import type {
|
||
|
Query,
|
||
|
UpdateCallback,
|
||
|
Update,
|
||
|
PageInfo,
|
||
|
} from './generic-data-provider';
|
||
|
import type {
|
||
|
ApolloClient,
|
||
|
FetchResult,
|
||
|
SubscriptionOptions,
|
||
|
OperationVariables,
|
||
|
ApolloQueryResult,
|
||
|
QueryOptions,
|
||
|
} from '@apollo/client';
|
||
|
import type { Subscription, Observable } from 'zen-observable-ts';
|
||
|
|
||
|
type Item = {
|
||
|
cursor: string;
|
||
|
node: {
|
||
|
id: string;
|
||
|
};
|
||
|
};
|
||
|
type Data = Item[];
|
||
|
type QueryData = {
|
||
|
data: Data;
|
||
|
pageInfo?: PageInfo;
|
||
|
totalCount?: number;
|
||
|
};
|
||
|
|
||
|
type SubscriptionData = QueryData;
|
||
|
type Delta = Data;
|
||
|
|
||
|
describe('data provider', () => {
|
||
|
const update = jest.fn<
|
||
|
ReturnType<Update<Data, Delta>>,
|
||
|
Parameters<Update<Data, Delta>>
|
||
|
>();
|
||
|
|
||
|
const callback = jest.fn<
|
||
|
ReturnType<UpdateCallback<Data, Delta>>,
|
||
|
Parameters<UpdateCallback<Data, Delta>>
|
||
|
>();
|
||
|
|
||
|
const query: Query<QueryData> = {
|
||
|
kind: 'Document',
|
||
|
definitions: [],
|
||
|
};
|
||
|
const subscriptionQuery: Query<SubscriptionData> = query;
|
||
|
|
||
|
const subscribe = makeDataProvider<QueryData, Data, SubscriptionData, Delta>(
|
||
|
query,
|
||
|
subscriptionQuery,
|
||
|
update,
|
||
|
(r) => r.data,
|
||
|
(r) => r.data
|
||
|
);
|
||
|
|
||
|
const first = 100;
|
||
|
const paginatedSubscribe = makeDataProvider<
|
||
|
QueryData,
|
||
|
Data,
|
||
|
SubscriptionData,
|
||
|
Delta
|
||
|
>(
|
||
|
query,
|
||
|
subscriptionQuery,
|
||
|
update,
|
||
|
(r) => r.data,
|
||
|
(r) => r.data,
|
||
|
{
|
||
|
first,
|
||
|
append: defaultAppend,
|
||
|
getPageInfo: (r) => r?.pageInfo ?? null,
|
||
|
getTotalCount: (r) => r?.totalCount,
|
||
|
}
|
||
|
);
|
||
|
|
||
|
const generateData = (start = 0, size = first) => {
|
||
|
return new Array(size).fill(null).map((v, i) => ({
|
||
|
cursor: (i + start + 1).toString(),
|
||
|
node: {
|
||
|
id: (i + start + 1).toString(),
|
||
|
},
|
||
|
}));
|
||
|
};
|
||
|
|
||
|
const clientSubscribeUnsubscribe = jest.fn();
|
||
|
const clientSubscribeSubscribe = jest.fn<
|
||
|
Subscription,
|
||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
[(value: FetchResult<SubscriptionData>) => void, (error: any) => void]
|
||
|
>(() => ({
|
||
|
unsubscribe: clientSubscribeUnsubscribe,
|
||
|
closed: false,
|
||
|
}));
|
||
|
|
||
|
const clientSubscribe = jest.fn<
|
||
|
Observable<FetchResult<SubscriptionData>>,
|
||
|
[SubscriptionOptions<OperationVariables, SubscriptionData>]
|
||
|
>(
|
||
|
() =>
|
||
|
({
|
||
|
subscribe: clientSubscribeSubscribe,
|
||
|
} as unknown as Observable<FetchResult<SubscriptionData>>)
|
||
|
);
|
||
|
|
||
|
const clientQueryPromise: {
|
||
|
resolve?: (
|
||
|
value:
|
||
|
| ApolloQueryResult<QueryData>
|
||
|
| PromiseLike<ApolloQueryResult<QueryData>>
|
||
|
) => void;
|
||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
reject?: (reason?: any) => void;
|
||
|
} = {};
|
||
|
|
||
|
const clientQuery = jest.fn<
|
||
|
Promise<ApolloQueryResult<QueryData>>,
|
||
|
[QueryOptions<OperationVariables, QueryData>]
|
||
|
>(() => {
|
||
|
return new Promise((resolve, reject) => {
|
||
|
clientQueryPromise.resolve = resolve;
|
||
|
clientQueryPromise.reject = reject;
|
||
|
});
|
||
|
});
|
||
|
|
||
|
const client = {
|
||
|
query: clientQuery,
|
||
|
subscribe: clientSubscribe,
|
||
|
} as unknown as ApolloClient<object>;
|
||
|
|
||
|
const resolveQuery = async (data: QueryData) => {
|
||
|
if (clientQueryPromise.resolve) {
|
||
|
await clientQueryPromise.resolve({
|
||
|
data,
|
||
|
loading: false,
|
||
|
networkStatus: 8,
|
||
|
});
|
||
|
}
|
||
|
};
|
||
|
|
||
|
it('memoize instance and unsubscribe if no subscribers', () => {
|
||
|
const subscription1 = subscribe(jest.fn(), client);
|
||
|
const subscription2 = subscribe(jest.fn(), client);
|
||
|
expect(clientSubscribeSubscribe.mock.calls.length).toEqual(1);
|
||
|
subscription1.unsubscribe();
|
||
|
expect(clientSubscribeUnsubscribe.mock.calls.length).toEqual(0);
|
||
|
subscription2.unsubscribe();
|
||
|
expect(clientSubscribeUnsubscribe.mock.calls.length).toEqual(1);
|
||
|
});
|
||
|
|
||
|
it('calls callback before and after initial fetch', async () => {
|
||
|
callback.mockClear();
|
||
|
const data: Item[] = [];
|
||
|
const subscription = subscribe(callback, client);
|
||
|
expect(callback.mock.calls.length).toBe(1);
|
||
|
expect(callback.mock.calls[0][0].data).toBe(null);
|
||
|
expect(callback.mock.calls[0][0].loading).toBe(true);
|
||
|
await resolveQuery({ data });
|
||
|
expect(callback.mock.calls.length).toBe(2);
|
||
|
expect(callback.mock.calls[1][0].data).toBe(data);
|
||
|
expect(callback.mock.calls[1][0].loading).toBe(false);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it('calls update and callback on each update', async () => {
|
||
|
const data: Item[] = [];
|
||
|
const subscription = subscribe(callback, client);
|
||
|
await resolveQuery({ data });
|
||
|
const delta: Item[] = [];
|
||
|
update.mockImplementationOnce((data, delta) => [...data, ...delta]);
|
||
|
// calling onNext from client.subscribe({ query }).subscribe(onNext)
|
||
|
await clientSubscribeSubscribe.mock.calls[
|
||
|
clientSubscribeSubscribe.mock.calls.length - 1
|
||
|
][0]({ data: { data: delta } });
|
||
|
expect(update.mock.calls[update.mock.calls.length - 1][0]).toBe(data);
|
||
|
expect(update.mock.calls[update.mock.calls.length - 1][1]).toBe(delta);
|
||
|
expect(callback.mock.calls[callback.mock.calls.length - 1][0].delta).toBe(
|
||
|
delta
|
||
|
);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it("don't calls callback on update if data doesn't", async () => {
|
||
|
callback.mockClear();
|
||
|
const data: Item[] = [];
|
||
|
const subscription = subscribe(callback, client);
|
||
|
await resolveQuery({ data });
|
||
|
const delta: Item[] = [];
|
||
|
update.mockImplementationOnce((data, delta) => data);
|
||
|
const callbackCallsLength = callback.mock.calls.length;
|
||
|
// calling onNext from client.subscribe({ query }).subscribe(onNext)
|
||
|
await clientSubscribeSubscribe.mock.calls[
|
||
|
clientSubscribeSubscribe.mock.calls.length - 1
|
||
|
][0]({ data: { data: delta } });
|
||
|
expect(update.mock.calls[update.mock.calls.length - 1][0]).toBe(data);
|
||
|
expect(update.mock.calls[update.mock.calls.length - 1][1]).toBe(delta);
|
||
|
expect(callback.mock.calls.length).toBe(callbackCallsLength);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it('refetch data on reload', async () => {
|
||
|
clientQuery.mockClear();
|
||
|
clientSubscribeUnsubscribe.mockClear();
|
||
|
clientSubscribeSubscribe.mockClear();
|
||
|
const data: Item[] = [];
|
||
|
const subscription = subscribe(callback, client);
|
||
|
await resolveQuery({ data });
|
||
|
subscription.reload();
|
||
|
await resolveQuery({ data });
|
||
|
expect(clientQuery.mock.calls.length).toBe(2);
|
||
|
expect(clientSubscribeSubscribe.mock.calls.length).toBe(1);
|
||
|
expect(clientSubscribeUnsubscribe.mock.calls.length).toBe(0);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it('refetch data and restart subscription on reload with force', async () => {
|
||
|
clientQuery.mockClear();
|
||
|
clientSubscribeUnsubscribe.mockClear();
|
||
|
clientSubscribeSubscribe.mockClear();
|
||
|
const data: Item[] = [];
|
||
|
const subscription = subscribe(callback, client);
|
||
|
await resolveQuery({ data });
|
||
|
subscription.reload(true);
|
||
|
await resolveQuery({ data });
|
||
|
expect(clientQuery.mock.calls.length).toBe(2);
|
||
|
expect(clientSubscribeSubscribe.mock.calls.length).toBe(2);
|
||
|
expect(clientSubscribeUnsubscribe.mock.calls.length).toBe(1);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it('calls callback on flush', async () => {
|
||
|
callback.mockClear();
|
||
|
const data: Item[] = [];
|
||
|
const subscription = subscribe(callback, client);
|
||
|
await resolveQuery({ data });
|
||
|
const callbackCallsLength = callback.mock.calls.length;
|
||
|
subscription.flush();
|
||
|
expect(callback.mock.calls.length).toBe(callbackCallsLength + 1);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it('fills data with nulls if paginaton is enabled', async () => {
|
||
|
callback.mockClear();
|
||
|
const totalCount = 1000;
|
||
|
const data: Item[] = new Array(first).fill(null).map((v, i) => ({
|
||
|
cursor: i.toString(),
|
||
|
node: {
|
||
|
id: i.toString(),
|
||
|
},
|
||
|
}));
|
||
|
const subscription = paginatedSubscribe(callback, client);
|
||
|
await resolveQuery({
|
||
|
data,
|
||
|
totalCount,
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
},
|
||
|
});
|
||
|
expect(callback.mock.calls[1][0].data?.length).toBe(totalCount);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it('loads requested data blocks and inserts data with total count', async () => {
|
||
|
callback.mockClear();
|
||
|
const totalCount = 1000;
|
||
|
const subscription = paginatedSubscribe(callback, client);
|
||
|
await resolveQuery({
|
||
|
data: generateData(),
|
||
|
totalCount,
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
endCursor: '100',
|
||
|
},
|
||
|
});
|
||
|
|
||
|
// load next page
|
||
|
subscription.load();
|
||
|
let lastQueryArgs =
|
||
|
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
|
||
|
expect(lastQueryArgs?.variables?.pagination).toEqual({
|
||
|
after: '100',
|
||
|
first,
|
||
|
});
|
||
|
await resolveQuery({
|
||
|
data: generateData(100),
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
endCursor: '200',
|
||
|
},
|
||
|
});
|
||
|
|
||
|
// load page with skip
|
||
|
subscription.load(500, 600);
|
||
|
lastQueryArgs =
|
||
|
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
|
||
|
expect(lastQueryArgs?.variables?.pagination).toEqual({
|
||
|
after: '200',
|
||
|
first,
|
||
|
skip: 300,
|
||
|
});
|
||
|
await resolveQuery({
|
||
|
data: generateData(500),
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
endCursor: '600',
|
||
|
},
|
||
|
});
|
||
|
|
||
|
// load in the gap
|
||
|
subscription.load(400, 500);
|
||
|
lastQueryArgs =
|
||
|
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
|
||
|
expect(lastQueryArgs?.variables?.pagination).toEqual({
|
||
|
after: '200',
|
||
|
first,
|
||
|
skip: 200,
|
||
|
});
|
||
|
await resolveQuery({
|
||
|
data: generateData(400),
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
endCursor: '500',
|
||
|
},
|
||
|
});
|
||
|
|
||
|
// load page after last block
|
||
|
subscription.load(700, 800);
|
||
|
lastQueryArgs =
|
||
|
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
|
||
|
expect(lastQueryArgs?.variables?.pagination).toEqual({
|
||
|
after: '600',
|
||
|
first,
|
||
|
skip: 100,
|
||
|
});
|
||
|
await resolveQuery({
|
||
|
data: generateData(700),
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
endCursor: '800',
|
||
|
},
|
||
|
});
|
||
|
|
||
|
// load last page shorter than expected
|
||
|
subscription.load(950, 1050);
|
||
|
lastQueryArgs =
|
||
|
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
|
||
|
expect(lastQueryArgs?.variables?.pagination).toEqual({
|
||
|
after: '800',
|
||
|
first,
|
||
|
skip: 150,
|
||
|
});
|
||
|
await resolveQuery({
|
||
|
data: generateData(950, 20),
|
||
|
pageInfo: {
|
||
|
hasNextPage: false,
|
||
|
endCursor: '970',
|
||
|
},
|
||
|
});
|
||
|
let lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
|
||
|
expect(lastCallbackArgs[0].totalCount).toBe(970);
|
||
|
|
||
|
// load next page when pageInfo.hasNextPage === false
|
||
|
const clientQueryCallsLength = clientQuery.mock.calls.length;
|
||
|
subscription.load();
|
||
|
expect(clientQuery.mock.calls.length).toBe(clientQueryCallsLength);
|
||
|
|
||
|
// load last page longer than expected
|
||
|
subscription.load(960, 1000);
|
||
|
lastQueryArgs =
|
||
|
clientQuery.mock.calls[clientQuery.mock.calls.length - 1][0];
|
||
|
expect(lastQueryArgs?.variables?.pagination).toEqual({
|
||
|
after: '960',
|
||
|
first,
|
||
|
});
|
||
|
await resolveQuery({
|
||
|
data: generateData(960, 40),
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
endCursor: '1000',
|
||
|
},
|
||
|
});
|
||
|
lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
|
||
|
expect(lastCallbackArgs[0].totalCount).toBe(1000);
|
||
|
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it('loads requested data blocks and inserts data without totalCount', async () => {
|
||
|
callback.mockClear();
|
||
|
const totalCount = undefined;
|
||
|
const subscription = paginatedSubscribe(callback, client);
|
||
|
await resolveQuery({
|
||
|
data: generateData(),
|
||
|
totalCount,
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
endCursor: '100',
|
||
|
},
|
||
|
});
|
||
|
let lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
|
||
|
expect(lastCallbackArgs[0].totalCount).toBe(undefined);
|
||
|
|
||
|
// load next page
|
||
|
subscription.load();
|
||
|
await resolveQuery({
|
||
|
data: generateData(100),
|
||
|
pageInfo: {
|
||
|
hasNextPage: true,
|
||
|
endCursor: '200',
|
||
|
},
|
||
|
});
|
||
|
lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
|
||
|
expect(lastCallbackArgs[0].totalCount).toBe(undefined);
|
||
|
|
||
|
// load last page
|
||
|
subscription.load();
|
||
|
await resolveQuery({
|
||
|
data: generateData(200, 50),
|
||
|
pageInfo: {
|
||
|
hasNextPage: false,
|
||
|
endCursor: '250',
|
||
|
},
|
||
|
});
|
||
|
lastCallbackArgs = callback.mock.calls[callback.mock.calls.length - 1];
|
||
|
expect(lastCallbackArgs[0].totalCount).toBe(250);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
|
||
|
it('sets total count when first page has no next page', async () => {
|
||
|
const subscription = paginatedSubscribe(callback, client);
|
||
|
await resolveQuery({
|
||
|
data: generateData(),
|
||
|
pageInfo: {
|
||
|
hasNextPage: false,
|
||
|
endCursor: '100',
|
||
|
},
|
||
|
});
|
||
|
const lastCallbackArgs =
|
||
|
callback.mock.calls[callback.mock.calls.length - 1];
|
||
|
expect(lastCallbackArgs[0].totalCount).toBe(100);
|
||
|
subscription.unsubscribe();
|
||
|
});
|
||
|
});
|