import type { QueryVariables } from '@dhis2/data-engine'
import { renderHook, waitFor, act } from '@testing-library/react'
import * as React from 'react'
import { CustomDataProvider } from '../components/CustomDataProvider'
import { useDataQuery } from './useDataQuery'
describe('useDataQuery', () => {
describe('parameters: onComplete', () => {
it('Should call onComplete with the data after a successful fetch', async () => {
const query = { x: { resource: 'answer' } }
const data = { answer: 42 }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const onComplete = jest.fn()
const onError = jest.fn()
const { result } = renderHook(
() => useDataQuery(query, { lazy: false, onComplete, onError }),
{ wrapper }
)
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith({ x: 42 })
expect(onError).not.toHaveBeenCalled()
expect(result.current).toMatchObject({
data: { x: 42 },
})
})
})
})
describe('parameters: onError', () => {
it('Should call onError with the error after a fetch error', async () => {
const expectedError = new Error('Something went wrong')
const query = { x: { resource: 'answer' } }
const data = {
answer: () => {
throw expectedError
},
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const onComplete = jest.fn()
const onError = jest.fn()
const { result } = renderHook(
() => useDataQuery(query, { onError, onComplete }),
{ wrapper }
)
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(expectedError)
expect(onComplete).not.toHaveBeenCalled()
expect(result.current).toMatchObject({
error: expectedError,
})
})
})
})
describe('parameters: variables', () => {
it('Should ignore new variables from rerenders', async () => {
const one = 'one'
const two = 'two'
const resultOne = 1
const resultTwo = 2
const query = {
x: {
resource: 'answer',
id: ({ id }: QueryVariables) => id as string,
},
}
const mockSpy = jest.fn((_, { id }) => {
switch (id) {
case one:
return Promise.resolve(resultOne)
case two:
return Promise.resolve(resultTwo)
}
return Promise.resolve(undefined)
})
const data = {
answer: mockSpy,
}
const wrapper = ({ children }: { children?: any }) => (
{children}
)
const initialProps = {
query,
options: { variables: { id: one } },
}
const { result, rerender } = renderHook(
(props) => useDataQuery(props.query, props.options),
{
wrapper,
initialProps,
}
)
expect(mockSpy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(result.current).toMatchObject({
data: { x: resultOne },
})
})
act(() => {
const newProps = {
query,
options: {
variables: { id: two },
},
}
rerender(newProps)
})
expect(mockSpy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject({
data: { x: resultOne },
})
})
})
describe('parameters: lazy', () => {
it('Should be false by default', async () => {
const query = { x: { resource: 'answer' } }
const mockSpy = jest.fn(() => Promise.resolve(42))
const data = { answer: mockSpy }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useDataQuery(query), {
wrapper,
})
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(mockSpy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
})
})
describe('internal: caching', () => {
it('Should return data from the cache if it is not stale', async () => {
// Keep cached data forever, see: https://react-query.tanstack.com/reference/useQuery
const queryClientOptions = {
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
}
const answers = [42, 43]
const mockSpy = jest.fn(() => Promise.resolve(answers.shift()))
const data = {
answer: mockSpy,
}
const query = {
x: { resource: 'answer' },
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useDataQuery(query), {
wrapper,
})
expect(mockSpy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
// Now the cache will contain a value for 'answer' without variables
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
act(() => {
// Add a variable to the request to ensure a different cache key
result.current.refetch({ one: 1 })
})
expect(mockSpy).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({
loading: true,
})
await waitFor(() => {
// Now the cache will contain a value for 'answer' with and without variables
expect(result.current).toMatchObject({
loading: false,
data: { x: 43 },
})
})
act(() => {
// Request the resource without variables again
result.current.refetch({ one: undefined })
})
// This should return the resource from the cache without fetching
expect(mockSpy).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
})
describe('internal: deduplication', () => {
it('Should deduplicate identical requests', async () => {
const mockSpy = jest.fn(() => Promise.resolve(42))
const data = {
answer: mockSpy,
}
const query = {
x: { resource: 'answer' },
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => {
const one = useDataQuery(query)
const two = useDataQuery(query)
const three = useDataQuery(query)
return [one, two, three]
},
{
wrapper,
}
)
const loading = {
loading: true,
called: true,
}
expect(mockSpy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject([loading, loading, loading])
await waitFor(() => {
const done = {
loading: false,
called: true,
data: { x: 42 },
}
expect(mockSpy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject([done, done, done])
})
})
})
describe('return values: data', () => {
it('Should return the data for a single resource query on success', async () => {
const query = { x: { resource: 'answer' } }
const data = { answer: 42 }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useDataQuery(query), {
wrapper,
})
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
})
it('Should return the data for a multiple resource query on success', async () => {
const query = {
x: { resource: 'answer' },
y: { resource: 'opposite' },
}
const data = { answer: 42, opposite: 24 }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useDataQuery(query), {
wrapper,
})
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42, y: 24 },
})
})
})
})
describe('return values: error', () => {
it('Should return errors it encounters for a single resource query', async () => {
const expectedError = new Error('Something went wrong')
const query = { x: { resource: 'answer' } }
const data = {
answer: () => {
throw expectedError
},
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useDataQuery(query), {
wrapper,
})
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
error: expectedError,
})
})
})
it('Should return errors it encounters for multiple resource queries', async () => {
const expectedError = new Error('Something went wrong')
const query = {
x: { resource: 'answer' },
y: { resource: 'opposite' },
}
const data = {
answer: () => {
throw expectedError
},
opposite: 24,
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useDataQuery(query), {
wrapper,
})
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
error: expectedError,
})
})
})
})
describe('return values: refetch', () => {
it('Should be stable if the query variables change', async () => {
let count = 0
const spy = jest.fn(() => {
count++
return Promise.resolve(count)
})
const data = {
answer: spy,
}
const query = {
x: { resource: 'answer' },
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { lazy: true }),
{
wrapper,
}
)
expect(spy).not.toHaveBeenCalled()
const initialRefetch = result.current.refetch
act(() => {
initialRefetch()
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 1 },
})
})
expect(spy).toHaveBeenCalledTimes(1)
act(() => {
initialRefetch()
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 2 },
})
})
expect(spy).toHaveBeenCalledTimes(2)
expect(initialRefetch).toBe(result.current.refetch)
})
it('Should only trigger a single request when refetch is called on a lazy query with new variables', async () => {
const spy = jest.fn((type, query) => {
if (query.id === '1') {
return Promise.resolve(42)
}
return Promise.resolve(0)
})
const data = {
answer: spy,
}
const query = {
x: {
resource: 'answer',
id: ({ id }: QueryVariables) => id as string,
},
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { lazy: true }),
{
wrapper,
}
)
expect(spy).not.toHaveBeenCalled()
act(() => {
result.current.refetch({ id: '1' })
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
expect(spy).toHaveBeenCalledTimes(1)
})
it('Should only trigger a single request when refetch is called on a lazy query with identical variables', async () => {
const spy = jest.fn((type, query) => {
if (query.id === '1') {
return Promise.resolve(42)
}
return Promise.resolve(0)
})
const data = {
answer: spy,
}
const query = {
x: {
resource: 'answer',
id: ({ id }: QueryVariables) => id as string,
},
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() =>
useDataQuery(query, { lazy: true, variables: { id: '1' } }),
{
wrapper,
}
)
expect(spy).not.toHaveBeenCalled()
act(() => {
result.current.refetch({ id: '1' })
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
expect(spy).toHaveBeenCalledTimes(1)
})
it('Should have a stable identity if the variables have not changed', async () => {
const data = {
answer: () => Promise.resolve(42),
}
const query = { x: { resource: 'answer' } }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result, rerender } = renderHook(() => useDataQuery(query), {
wrapper,
})
const firstRefetch = result.current.refetch
// await waitFor(() => {})
act(() => {
result.current.refetch()
/**
* FIXME: https://github.com/tannerlinsley/react-query/issues/2481
* This forced rerender is not necessary in the app, just when testing.
* It is unclear why.
*/
rerender()
})
await waitFor(() => {
expect(result.current.refetch).toBe(firstRefetch)
})
})
it('Should return stale data and set loading to true on refetch', async () => {
const answers = [42, 43]
const mockSpy = jest.fn(() => Promise.resolve(answers.shift()))
const data = {
answer: mockSpy,
}
const query = { x: { resource: 'answer' } }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result, rerender } = renderHook(() => useDataQuery(query), {
wrapper,
})
expect(mockSpy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
act(() => {
result.current.refetch()
/**
* FIXME: https://github.com/tannerlinsley/react-query/issues/2481
* This forced rerender is not necessary in the app, just when testing.
* It is unclear why.
*/
rerender()
})
expect(mockSpy).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({
loading: false,
fetching: true,
called: true,
data: { x: 42 },
})
await waitFor(() => {
expect(mockSpy).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({
loading: false,
fetching: false,
called: true,
data: { x: 43 },
})
})
})
it('Should not fetch until refetch has been called if lazy', async () => {
const query = { x: { resource: 'answer' } }
const mockSpy = jest.fn(() => Promise.resolve(42))
const data = { answer: mockSpy }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { lazy: true }),
{ wrapper }
)
expect(mockSpy).toHaveBeenCalledTimes(0)
expect(result.current).toMatchObject({
loading: false,
called: false,
})
act(() => {
result.current.refetch()
})
expect(mockSpy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject({
loading: true,
called: true,
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
})
it('Should call onComplete after a successful refetch', async () => {
const query = { x: { resource: 'answer' } }
const data = { answer: 42 }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const onComplete = jest.fn()
const onError = jest.fn()
const { result } = renderHook(
() => useDataQuery(query, { lazy: true, onComplete, onError }),
{ wrapper }
)
act(() => {
result.current.refetch()
})
await waitFor(() => {
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
expect(onComplete).toHaveBeenCalledWith({ x: 42 })
expect(onError).not.toHaveBeenCalled()
})
})
it('Should call onError after a failed refetch', async () => {
const expectedError = new Error('Something went wrong')
const query = { x: { resource: 'answer' } }
const data = {
answer: () => {
throw expectedError
},
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const onComplete = jest.fn()
const onError = jest.fn()
const { result } = renderHook(
() => useDataQuery(query, { lazy: true, onComplete, onError }),
{ wrapper }
)
act(() => {
result.current.refetch()
})
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(expectedError)
expect(onComplete).not.toHaveBeenCalled()
expect(result.current).toMatchObject({
error: expectedError,
})
})
})
it('Should refetch when refetch is called with variables that resolve to the same query key', async () => {
const variables = { one: 1, two: 2, three: 3 }
const query = {
x: {
resource: 'answer',
params: ({ one, two, three }: QueryVariables) => ({
one,
two,
three,
}),
},
}
const spy = jest.fn(() => Promise.resolve(42))
const data = { answer: spy }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { variables }),
{ wrapper }
)
await waitFor(() => {
expect(spy).toHaveBeenCalledTimes(1)
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
act(() => {
result.current.refetch(variables)
})
await waitFor(() => {
expect(spy).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
})
it('Should return a promise that resolves with the data on success when refetching and lazy', async () => {
const query = { x: { resource: 'answer' } }
const data = { answer: 42 }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { lazy: true }),
{ wrapper }
)
let ourPromise!: Promise
act(() => {
// This refetch will trigger our own refetch logic as the query is lazy
ourPromise = result.current.refetch()
})
await waitFor(() => {
expect(ourPromise).resolves.toEqual({ x: 42 })
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
})
it('Should return a promise that resolves with the data on success when refetching and not lazy', async () => {
const query = { x: { resource: 'answer' } }
const data = { answer: 42 }
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { lazy: false }),
{ wrapper }
)
let reactQueryPromise!: Promise
act(() => {
// This refetch will trigger react query's refetch logic as the query is not lazy
reactQueryPromise = result.current.refetch()
})
await waitFor(() => {
expect(reactQueryPromise).resolves.toEqual({ x: 42 })
expect(result.current).toMatchObject({
loading: false,
called: true,
data: { x: 42 },
})
})
})
it('Should return a promise that does not reject on errors when refetching and lazy', async () => {
const expectedError = new Error('Something went wrong')
const query = { x: { resource: 'answer' } }
const data = {
answer: () => {
throw expectedError
},
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { lazy: true }),
{ wrapper }
)
let ourPromise!: Promise
act(() => {
// This refetch will trigger our own refetch logic as the query is lazy
ourPromise = result.current.refetch()
})
await waitFor(() => {
expect(ourPromise).resolves.toBeUndefined()
expect(result.current).toMatchObject({
error: expectedError,
})
})
})
it('Should return a promise that does not reject on errors when refetching and not lazy', async () => {
const expectedError = new Error('Something went wrong')
const query = { x: { resource: 'answer' } }
const data = {
answer: () => {
throw expectedError
},
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { lazy: false }),
{ wrapper }
)
let reactQueryPromise!: Promise
act(() => {
// This refetch will trigger react query's refetch logic as the query is not lazy
reactQueryPromise = result.current.refetch()
})
await waitFor(() => {
expect(reactQueryPromise).resolves.toBeUndefined()
expect(result.current).toMatchObject({
error: expectedError,
})
})
})
it('Should accept new variables from refetch', async () => {
const one = 'one'
const two = 'two'
const resultOne = 1
const resultTwo = 2
const query = {
x: {
resource: 'answer',
id: ({ id }: QueryVariables) => id as string,
},
}
const mockSpy = jest.fn((_, { id }) => {
switch (id) {
case one:
return Promise.resolve(resultOne)
case two:
return Promise.resolve(resultTwo)
}
return Promise.resolve(undefined)
})
const data = {
answer: mockSpy,
}
const wrapper = ({ children }: { children?: any }) => (
{children}
)
const initialProps = {
query,
options: { variables: { id: one } },
}
const { result } = renderHook(
(props) => useDataQuery(props.query, props.options),
{
wrapper,
initialProps,
}
)
await waitFor(() => {
expect(result.current).toMatchObject({
data: { x: resultOne },
})
})
act(() => {
result.current.refetch({ id: two })
})
await waitFor(() => {
expect(mockSpy).toHaveBeenCalledTimes(2)
expect(result.current).toMatchObject({
data: { x: resultTwo },
})
})
})
it('Should merge new variables from refetch with the existing variables', async () => {
const initialVariables = { one: 1, two: 2, three: 3 }
const newVariables = { two: 1, three: 1 }
const query = {
x: {
resource: 'answer',
params: ({ one, two, three }: QueryVariables) => ({
one,
two,
three,
}),
},
}
const mockSpy = jest.fn(() => Promise.resolve(42))
const data = {
answer: mockSpy,
}
const wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
)
const { result } = renderHook(
() => useDataQuery(query, { variables: initialVariables }),
{ wrapper }
)
await waitFor(() => {
expect(mockSpy).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ params: initialVariables }),
expect.anything()
)
})
act(() => {
result.current.refetch(newVariables)
})
await waitFor(() => {
expect(mockSpy).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({
params: {
...initialVariables,
...newVariables,
},
}),
expect.anything()
)
})
})
})
})