Build NapCat Artifacts / Windows Loader Test (push) Canceled after 0s
Build NapCat Artifacts / Type Check & Test (push) Canceled after 0s
Build NapCat Artifacts / Build NapCat.Framework (push) Canceled after 0s
Build NapCat Artifacts / Build NapCat.Shell (push) Canceled after 0s
663 lines
18 KiB
Plaintext
663 lines
18 KiB
Plaintext
import { createApi } from '@reduxjs/toolkit/query/react'
|
|
import { createAction } from '@reduxjs/toolkit'
|
|
import {
|
|
actionsReducer,
|
|
hookWaitFor,
|
|
setupApiStore,
|
|
} from '../../tests/utils/helpers'
|
|
import {
|
|
render,
|
|
renderHook,
|
|
act,
|
|
waitFor,
|
|
screen,
|
|
} from '@testing-library/react'
|
|
import { delay } from 'msw'
|
|
|
|
interface Post {
|
|
id: string
|
|
title: string
|
|
contents: string
|
|
}
|
|
|
|
interface FolderT {
|
|
id: number
|
|
children: FolderT[]
|
|
}
|
|
|
|
const baseQuery = vi.fn()
|
|
beforeEach(() => baseQuery.mockReset())
|
|
|
|
const postAddedAction = createAction<string>('postAdded')
|
|
|
|
const api = createApi({
|
|
baseQuery: (...args: any[]) => {
|
|
const result = baseQuery(...args)
|
|
if (typeof result === 'object' && 'then' in result)
|
|
return result
|
|
.then((data: any) => ({ data, meta: 'meta' }))
|
|
.catch((e: any) => ({ error: e }))
|
|
return { data: result, meta: 'meta' }
|
|
},
|
|
tagTypes: ['Post', 'Folder'],
|
|
endpoints: (build) => ({
|
|
getPosts: build.query<Post[], void>({ query: () => '/posts' }),
|
|
post: build.query<Post, string>({
|
|
query: (id) => `post/${id}`,
|
|
providesTags: ['Post'],
|
|
}),
|
|
updatePost: build.mutation<void, Pick<Post, 'id'> & Partial<Post>>({
|
|
query: ({ id, ...patch }) => ({
|
|
url: `post/${id}`,
|
|
method: 'PATCH',
|
|
body: patch,
|
|
}),
|
|
async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
|
|
const currentItem = api.endpoints.post.select(arg.id)(getState())
|
|
if (currentItem?.data) {
|
|
dispatch(
|
|
api.util.upsertQueryData('post', arg.id, {
|
|
...currentItem.data,
|
|
...arg,
|
|
}),
|
|
)
|
|
}
|
|
},
|
|
invalidatesTags: (result) => (result ? ['Post'] : []),
|
|
}),
|
|
post2: build.query<Post, string>({
|
|
queryFn: async (id) => {
|
|
await delay(20)
|
|
return { data: { id, title: 'All about cheese.', contents: 'TODO' } }
|
|
},
|
|
}),
|
|
postWithSideEffect: build.query<Post, string>({
|
|
query: (id) => `post/${id}`,
|
|
providesTags: (result) => {
|
|
if (result) {
|
|
return [{ type: 'Post', id: result.id } as const]
|
|
}
|
|
return []
|
|
},
|
|
async onCacheEntryAdded(arg, api) {
|
|
// Verify that lifecycle promise resolution works
|
|
const res = await api.cacheDataLoaded
|
|
|
|
// and leave a side effect we can check in the test
|
|
api.dispatch(postAddedAction(res.data.id))
|
|
},
|
|
keepUnusedDataFor: 0.1,
|
|
}),
|
|
getFolder: build.query<FolderT, number>({
|
|
queryFn: async (args) => {
|
|
return {
|
|
data: {
|
|
id: args,
|
|
// Folder contains children that are as well folders
|
|
children: [{ id: 2, children: [] }],
|
|
},
|
|
}
|
|
},
|
|
providesTags: (result, err, args) => [{ type: 'Folder', id: args }],
|
|
onQueryStarted: async (args, queryApi) => {
|
|
const { data } = await queryApi.queryFulfilled
|
|
|
|
// Upsert getFolder endpoint with children from response data
|
|
const upsertData = data.children.map((child) => ({
|
|
arg: child.id,
|
|
endpointName: 'getFolder' as const,
|
|
value: child,
|
|
}))
|
|
|
|
queryApi.dispatch(api.util.upsertQueryEntries(upsertData))
|
|
},
|
|
}),
|
|
}),
|
|
})
|
|
|
|
const storeRef = setupApiStore(api, { ...actionsReducer })
|
|
|
|
describe('basic lifecycle', () => {
|
|
let onStart = vi.fn(),
|
|
onError = vi.fn(),
|
|
onSuccess = vi.fn()
|
|
|
|
const extendedApi = api.injectEndpoints({
|
|
endpoints: (build) => ({
|
|
test: build.mutation({
|
|
query: (x) => x,
|
|
async onQueryStarted(arg, api) {
|
|
onStart(arg)
|
|
try {
|
|
const result = await api.queryFulfilled
|
|
onSuccess(result)
|
|
} catch (e) {
|
|
onError(e)
|
|
}
|
|
},
|
|
}),
|
|
}),
|
|
overrideExisting: true,
|
|
})
|
|
|
|
beforeEach(() => {
|
|
onStart.mockReset()
|
|
onError.mockReset()
|
|
onSuccess.mockReset()
|
|
})
|
|
|
|
test('Does basic inserts and upserts', async () => {
|
|
const newPost: Post = {
|
|
id: '3',
|
|
contents: 'Inserted content',
|
|
title: 'Inserted title',
|
|
}
|
|
const insertPromise = storeRef.store.dispatch(
|
|
api.util.upsertQueryData('post', newPost.id, newPost),
|
|
)
|
|
|
|
await insertPromise
|
|
|
|
const selectPost3 = api.endpoints.post.select(newPost.id)
|
|
const insertedPostEntry = selectPost3(storeRef.store.getState())
|
|
expect(insertedPostEntry.isSuccess).toBe(true)
|
|
expect(insertedPostEntry.data).toEqual(newPost)
|
|
|
|
const updatedPost: Post = {
|
|
id: '3',
|
|
contents: 'Updated content',
|
|
title: 'Updated title',
|
|
}
|
|
|
|
const updatePromise = storeRef.store.dispatch(
|
|
api.util.upsertQueryData('post', updatedPost.id, updatedPost),
|
|
)
|
|
|
|
await updatePromise
|
|
|
|
const updatedPostEntry = selectPost3(storeRef.store.getState())
|
|
|
|
expect(updatedPostEntry.isSuccess).toBe(true)
|
|
expect(updatedPostEntry.data).toEqual(updatedPost)
|
|
})
|
|
|
|
test('success', async () => {
|
|
const { result } = renderHook(
|
|
() => extendedApi.endpoints.test.useMutation(),
|
|
{ wrapper: storeRef.wrapper },
|
|
)
|
|
|
|
baseQuery.mockResolvedValue('success')
|
|
|
|
expect(onStart).not.toHaveBeenCalled()
|
|
expect(baseQuery).not.toHaveBeenCalled()
|
|
act(() => void result.current[0]('arg'))
|
|
expect(onStart).toHaveBeenCalledWith('arg')
|
|
expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined)
|
|
|
|
expect(onError).not.toHaveBeenCalled()
|
|
expect(onSuccess).not.toHaveBeenCalled()
|
|
await act(() => delay(5))
|
|
expect(onError).not.toHaveBeenCalled()
|
|
expect(onSuccess).toHaveBeenCalledWith({ data: 'success', meta: 'meta' })
|
|
})
|
|
|
|
test('error', async () => {
|
|
const { result } = renderHook(
|
|
() => extendedApi.endpoints.test.useMutation(),
|
|
{ wrapper: storeRef.wrapper },
|
|
)
|
|
|
|
baseQuery.mockRejectedValueOnce('error')
|
|
|
|
expect(onStart).not.toHaveBeenCalled()
|
|
expect(baseQuery).not.toHaveBeenCalled()
|
|
act(() => void result.current[0]('arg'))
|
|
expect(onStart).toHaveBeenCalledWith('arg')
|
|
expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined)
|
|
|
|
expect(onError).not.toHaveBeenCalled()
|
|
expect(onSuccess).not.toHaveBeenCalled()
|
|
await act(() => delay(5))
|
|
expect(onError).toHaveBeenCalledWith({
|
|
error: 'error',
|
|
isUnhandledError: false,
|
|
meta: undefined,
|
|
})
|
|
expect(onSuccess).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('upsertQueryData', () => {
|
|
test('inserts cache entry', async () => {
|
|
baseQuery
|
|
.mockResolvedValueOnce({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'TODO',
|
|
})
|
|
// TODO I have no idea why the query is getting called multiple times,
|
|
// but passing an additional mocked value (_any_ value)
|
|
// seems to silence some annoying "got an undefined result" logging
|
|
.mockResolvedValueOnce(42)
|
|
const { result } = renderHook(() => api.endpoints.post.useQuery('3'), {
|
|
wrapper: storeRef.wrapper,
|
|
})
|
|
await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
|
|
|
|
const dataBefore = result.current.data
|
|
expect(dataBefore).toEqual({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'TODO',
|
|
})
|
|
|
|
await act(async () => {
|
|
storeRef.store.dispatch(
|
|
api.util.upsertQueryData('post', '3', {
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'I love cheese!',
|
|
}),
|
|
)
|
|
})
|
|
|
|
expect(result.current.data).not.toBe(dataBefore)
|
|
expect(result.current.data).toEqual({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'I love cheese!',
|
|
})
|
|
})
|
|
|
|
test('does update non-existing values', async () => {
|
|
baseQuery
|
|
// throw an error to make sure there is no cached data
|
|
.mockImplementationOnce(async () => {
|
|
throw new Error('failed to load')
|
|
})
|
|
.mockResolvedValueOnce(42)
|
|
|
|
// a subscriber is needed to have the data stay in the cache
|
|
// Not sure if this is the wanted behavior, I would have liked
|
|
// it to stay in the cache for the x amount of time the cache
|
|
// is preserved normally after the last subscriber was unmounted
|
|
const { result, rerender } = renderHook(
|
|
() => api.endpoints.post.useQuery('4'),
|
|
{ wrapper: storeRef.wrapper },
|
|
)
|
|
await hookWaitFor(() => expect(result.current.isError).toBeTruthy())
|
|
|
|
// upsert the data
|
|
act(() => {
|
|
storeRef.store.dispatch(
|
|
api.util.upsertQueryData('post', '4', {
|
|
id: '4',
|
|
title: 'All about cheese',
|
|
contents: 'I love cheese!',
|
|
}),
|
|
)
|
|
})
|
|
|
|
// rerender the hook
|
|
rerender()
|
|
// wait until everything has settled
|
|
await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())
|
|
|
|
// the cached data is returned as the result
|
|
expect(result.current.data).toStrictEqual({
|
|
id: '4',
|
|
title: 'All about cheese',
|
|
contents: 'I love cheese!',
|
|
})
|
|
})
|
|
|
|
test('upsert while a normal query is running (success)', async () => {
|
|
const fetchedData = {
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'Yummy',
|
|
}
|
|
baseQuery.mockImplementation(() => delay(20).then(() => fetchedData))
|
|
const upsertedData = {
|
|
id: '3',
|
|
title: 'Data from a SSR Render',
|
|
contents: 'This is just some random data',
|
|
}
|
|
|
|
const selector = api.endpoints.post.select('3')
|
|
const fetchRes = storeRef.store.dispatch(api.endpoints.post.initiate('3'))
|
|
const upsertRes = storeRef.store.dispatch(
|
|
api.util.upsertQueryData('post', '3', upsertedData),
|
|
)
|
|
|
|
await upsertRes
|
|
let state = selector(storeRef.store.getState())
|
|
expect(state.data).toEqual(upsertedData)
|
|
|
|
await fetchRes
|
|
state = selector(storeRef.store.getState())
|
|
expect(state.data).toEqual(fetchedData)
|
|
})
|
|
test('upsert while a normal query is running (rejected)', async () => {
|
|
baseQuery.mockImplementationOnce(async () => {
|
|
await delay(20)
|
|
// eslint-disable-next-line no-throw-literal
|
|
throw 'Error!'
|
|
})
|
|
const upsertedData = {
|
|
id: '3',
|
|
title: 'Data from a SSR Render',
|
|
contents: 'This is just some random data',
|
|
}
|
|
|
|
const selector = api.endpoints.post.select('3')
|
|
const fetchRes = storeRef.store.dispatch(api.endpoints.post.initiate('3'))
|
|
const upsertRes = storeRef.store.dispatch(
|
|
api.util.upsertQueryData('post', '3', upsertedData),
|
|
)
|
|
|
|
await upsertRes
|
|
let state = selector(storeRef.store.getState())
|
|
expect(state.data).toEqual(upsertedData)
|
|
expect(state.isSuccess).toBeTruthy()
|
|
|
|
await fetchRes
|
|
state = selector(storeRef.store.getState())
|
|
expect(state.data).toEqual(upsertedData)
|
|
expect(state.isError).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('upsertQueryEntries', () => {
|
|
const posts: Post[] = [
|
|
{ id: '1', contents: 'A', title: 'A' },
|
|
{ id: '2', contents: 'B', title: 'B' },
|
|
{ id: '3', contents: 'C', title: 'C' },
|
|
]
|
|
|
|
const entriesAction = api.util.upsertQueryEntries([
|
|
{ endpointName: 'getPosts', arg: undefined, value: posts },
|
|
...posts.map((post) => ({
|
|
endpointName: 'postWithSideEffect' as const,
|
|
arg: post.id,
|
|
value: post,
|
|
})),
|
|
])
|
|
|
|
test('Upserts many entries at once', async () => {
|
|
storeRef.store.dispatch(entriesAction)
|
|
|
|
const state = storeRef.store.getState()
|
|
|
|
expect(api.endpoints.getPosts.select()(state).data).toBe(posts)
|
|
|
|
for (const post of posts) {
|
|
expect(api.endpoints.postWithSideEffect.select(post.id)(state).data).toBe(
|
|
post,
|
|
)
|
|
|
|
// Should have added tags
|
|
expect(state.api.provided.tags.Post[post.id]).toEqual([
|
|
`postWithSideEffect("${post.id}")`,
|
|
])
|
|
}
|
|
})
|
|
|
|
test('Triggers cache lifecycles and side effects', async () => {
|
|
storeRef.store.dispatch(entriesAction)
|
|
|
|
// Tricky timing. The cache data promises will be resolved
|
|
// in microtasks. We need to wait for them. Best to do this
|
|
// in a loop just to avoid a hardcoded delay, but also this
|
|
// needs to complete before `keepUnusedDataFor` expires them.
|
|
await waitFor(
|
|
() => {
|
|
const state = storeRef.store.getState()
|
|
|
|
// onCacheEntryAdded should have run for each post,
|
|
// including cache data being resolved
|
|
for (const post of posts) {
|
|
const matchingSideEffectAction = state.actions.find(
|
|
(action) =>
|
|
postAddedAction.match(action) && action.payload === post.id,
|
|
)
|
|
expect(matchingSideEffectAction).toBeTruthy()
|
|
}
|
|
|
|
const selectedData =
|
|
api.endpoints.postWithSideEffect.select('1')(state).data
|
|
|
|
expect(selectedData).toBe(posts[0])
|
|
},
|
|
{ timeout: 150, interval: 5 },
|
|
)
|
|
|
|
// The cache data should be removed after the keepUnusedDataFor time,
|
|
// so wait longer than that
|
|
await delay(300)
|
|
|
|
const stateAfter = storeRef.store.getState()
|
|
|
|
expect(api.endpoints.postWithSideEffect.select('1')(stateAfter).data).toBe(
|
|
undefined,
|
|
)
|
|
})
|
|
|
|
test('Handles repeated upserts and async lifecycles', async () => {
|
|
const StateForUpsertFolder = ({ folderId }: { folderId: number }) => {
|
|
const { status } = api.useGetFolderQuery(folderId)
|
|
|
|
return (
|
|
<>
|
|
<div>
|
|
Status getFolder with ID (
|
|
{folderId === 1 ? 'original request' : 'upserted'}) {folderId}:{' '}
|
|
<span data-testid={`status-${folderId}`}>{status}</span>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const Folder = () => {
|
|
const { data, isLoading, isError } = api.useGetFolderQuery(1)
|
|
|
|
return (
|
|
<div>
|
|
<h1>Folders</h1>
|
|
|
|
{isLoading && <div>Loading...</div>}
|
|
|
|
{isError && <div>Error...</div>}
|
|
|
|
<StateForUpsertFolder key={`state-${1}`} folderId={1} />
|
|
<StateForUpsertFolder key={`state-${2}`} folderId={2} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
render(<Folder />, { wrapper: storeRef.wrapper })
|
|
|
|
await waitFor(() => {
|
|
const { actions } = storeRef.store.getState()
|
|
// Inspection:
|
|
// - 2 inits
|
|
// - 2 pendings, 2 fulfilleds for the hook queries
|
|
// - 2 upserts
|
|
expect(actions.length).toBe(8)
|
|
expect(
|
|
actions.filter((a) => api.util.upsertQueryEntries.match(a)).length,
|
|
).toBe(2)
|
|
})
|
|
expect(screen.getByTestId('status-2').textContent).toBe('fulfilled')
|
|
})
|
|
})
|
|
|
|
describe('full integration', () => {
|
|
test('success case', async () => {
|
|
baseQuery
|
|
.mockResolvedValueOnce({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'TODO',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: '3',
|
|
title: 'Meanwhile, this changed server-side.',
|
|
contents: 'Delicious cheese!',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
id: '3',
|
|
title: 'Meanwhile, this changed server-side.',
|
|
contents: 'Delicious cheese!',
|
|
})
|
|
.mockResolvedValueOnce(42)
|
|
const { result } = renderHook(
|
|
() => ({
|
|
query: api.endpoints.post.useQuery('3'),
|
|
mutation: api.endpoints.updatePost.useMutation(),
|
|
}),
|
|
{ wrapper: storeRef.wrapper },
|
|
)
|
|
await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy())
|
|
|
|
expect(result.current.query.data).toEqual({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'TODO',
|
|
})
|
|
|
|
await act(async () => {
|
|
await result.current.mutation[0]({
|
|
id: '3',
|
|
contents: 'Delicious cheese!',
|
|
})
|
|
})
|
|
|
|
expect(result.current.query.data).toEqual({
|
|
id: '3',
|
|
title: 'Meanwhile, this changed server-side.',
|
|
contents: 'Delicious cheese!',
|
|
})
|
|
|
|
await hookWaitFor(() =>
|
|
expect(result.current.query.data).toEqual({
|
|
id: '3',
|
|
title: 'Meanwhile, this changed server-side.',
|
|
contents: 'Delicious cheese!',
|
|
}),
|
|
)
|
|
})
|
|
|
|
test('error case', async () => {
|
|
baseQuery
|
|
.mockResolvedValueOnce({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'TODO',
|
|
})
|
|
.mockRejectedValueOnce('some error!')
|
|
.mockResolvedValueOnce({
|
|
id: '3',
|
|
title: 'Meanwhile, this changed server-side.',
|
|
contents: 'TODO',
|
|
})
|
|
.mockResolvedValueOnce(42)
|
|
|
|
const { result } = renderHook(
|
|
() => ({
|
|
query: api.endpoints.post.useQuery('3'),
|
|
mutation: api.endpoints.updatePost.useMutation(),
|
|
}),
|
|
{ wrapper: storeRef.wrapper },
|
|
)
|
|
await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy())
|
|
|
|
expect(result.current.query.data).toEqual({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'TODO',
|
|
})
|
|
|
|
await act(async () => {
|
|
await result.current.mutation[0]({
|
|
id: '3',
|
|
contents: 'Delicious cheese!',
|
|
})
|
|
})
|
|
|
|
// optimistic update
|
|
expect(result.current.query.data).toEqual({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'Delicious cheese!',
|
|
})
|
|
|
|
// mutation failed - will not invalidate query and not refetch data from the server
|
|
await expect(() =>
|
|
hookWaitFor(
|
|
() =>
|
|
expect(result.current.query.data).toEqual({
|
|
id: '3',
|
|
title: 'Meanwhile, this changed server-side.',
|
|
contents: 'TODO',
|
|
}),
|
|
50,
|
|
),
|
|
).rejects.toBeTruthy()
|
|
|
|
act(() => void result.current.query.refetch())
|
|
|
|
// manually refetching gives up-to-date data
|
|
await hookWaitFor(
|
|
() =>
|
|
expect(result.current.query.data).toEqual({
|
|
id: '3',
|
|
title: 'Meanwhile, this changed server-side.',
|
|
contents: 'TODO',
|
|
}),
|
|
50,
|
|
)
|
|
})
|
|
|
|
test('Interop with in-flight requests', async () => {
|
|
await act(async () => {
|
|
const fetchRes = storeRef.store.dispatch(
|
|
api.endpoints.post2.initiate('3'),
|
|
)
|
|
|
|
const upsertRes = storeRef.store.dispatch(
|
|
api.util.upsertQueryData('post2', '3', {
|
|
id: '3',
|
|
title: 'Upserted title',
|
|
contents: 'Upserted contents',
|
|
}),
|
|
)
|
|
|
|
const selectEntry = api.endpoints.post2.select('3')
|
|
await waitFor(
|
|
() => {
|
|
const entry1 = selectEntry(storeRef.store.getState())
|
|
expect(entry1.data).toEqual({
|
|
id: '3',
|
|
title: 'Upserted title',
|
|
contents: 'Upserted contents',
|
|
})
|
|
},
|
|
{ interval: 1, timeout: 15 },
|
|
)
|
|
await waitFor(
|
|
() => {
|
|
const entry2 = selectEntry(storeRef.store.getState())
|
|
expect(entry2.data).toEqual({
|
|
id: '3',
|
|
title: 'All about cheese.',
|
|
contents: 'TODO',
|
|
})
|
|
},
|
|
{ interval: 1 },
|
|
)
|
|
})
|
|
})
|
|
})
|