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>, Parameters> >(); const callback = jest.fn< ReturnType>, Parameters> >(); const query: Query = { kind: 'Document', definitions: [], }; const subscriptionQuery: Query = query; const subscribe = makeDataProvider( 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) => void, (error: any) => void] >(() => ({ unsubscribe: clientSubscribeUnsubscribe, closed: false, })); const clientSubscribe = jest.fn< Observable>, [SubscriptionOptions] >( () => ({ subscribe: clientSubscribeSubscribe, } as unknown as Observable>) ); const clientQueryPromise: { resolve?: ( value: | ApolloQueryResult | PromiseLike> ) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any reject?: (reason?: any) => void; } = {}; const clientQuery = jest.fn< Promise>, [QueryOptions] >(() => { return new Promise((resolve, reject) => { clientQueryPromise.resolve = resolve; clientQueryPromise.reject = reject; }); }); const client = { query: clientQuery, subscribe: clientSubscribe, } as unknown as ApolloClient; 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(); }); });