* [AI Chat] Fix attachment progress not shown - Fix https://github.com/brave/brave-browser/issues/55694 When attaching any files, no progress is being shown. This is a regression caused by the following, either one of which alone stops progress being shown: - https://github.com/brave/brave-core/pull/35816 - https://github.com/brave/brave-core/pull/30429
1067 lines
32 KiB
TypeScript
1067 lines
32 KiB
TypeScript
// Copyright (c) 2025 The Brave Authors. All rights reserved.
|
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
import { expect, jest, describe, it, beforeEach } from '@jest/globals'
|
|
import { act, renderHook, RenderHookResult, waitFor } from '@testing-library/react'
|
|
import {
|
|
createInterfaceApi,
|
|
state,
|
|
event,
|
|
clearAllDataForTesting,
|
|
} from './create_interface_api'
|
|
import { endpointsFor } from './endpoints_for'
|
|
|
|
describe('createInterfaceApi', () => {
|
|
// Clear the shared QueryClient between tests to avoid cache pollution
|
|
beforeEach(() => {
|
|
clearAllDataForTesting()
|
|
})
|
|
|
|
it('should use a different key for each call', async () => {
|
|
function createTestApi(apiVersion: string) {
|
|
return createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: () => Promise.resolve({ apiVersion }),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
const apiV1 = createTestApi('v1')
|
|
|
|
function useApiV1Data() {
|
|
return apiV1.useGetData().data
|
|
}
|
|
|
|
let hookResult: RenderHookResult<{ apiVersion: string } | undefined, void> =
|
|
await act(async () => renderHook(useApiV1Data))
|
|
|
|
await act(async () => hookResult.rerender())
|
|
|
|
expect(hookResult.result.current).toEqual({ apiVersion: 'v1' })
|
|
|
|
const apiV2 = createTestApi('v2')
|
|
|
|
expect(await apiV1.getData.fetch()).toEqual({ apiVersion: 'v1' })
|
|
expect(await apiV2.getData.fetch()).toEqual({ apiVersion: 'v2' })
|
|
expect(apiV1.getData.current()).toEqual({ apiVersion: 'v1' })
|
|
expect(apiV2.getData.current()).toEqual({ apiVersion: 'v2' })
|
|
|
|
await act(async () => hookResult.rerender())
|
|
expect(hookResult.result.current).toEqual({ apiVersion: 'v1' })
|
|
})
|
|
|
|
it('should return results keyed by parameters', async () => {
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: (id: string) => {
|
|
return Promise.resolve({ id })
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// No initial / placeholder data, so don't expect the hook
|
|
// @ts-expect-error
|
|
expect(api.useGetfDataData).toBeUndefined()
|
|
|
|
// Typescript shouldn't allow an endpoint that isn't defined
|
|
// @ts-expect-error
|
|
expect(api.useDoesNotExist).toBeUndefined()
|
|
// @ts-expect-error
|
|
expect(api.doesNotExist).toBeUndefined()
|
|
|
|
// Check fetch is keyed by parameter
|
|
expect(await api.getData.fetch('1')).toEqual({ id: '1' })
|
|
expect(await api.getData.fetch('2')).toEqual({ id: '2' })
|
|
// Current should be keyed by parameter
|
|
expect(api.getData.current('1')).toEqual({ id: '1' })
|
|
expect(api.getData.current('2')).toEqual({ id: '2' })
|
|
expect(api.getData.current('3')).toBeUndefined()
|
|
// Update should be keyed by parameter
|
|
api.getData.update('1', { id: '3' })
|
|
expect(api.getData.current('1')).toEqual({ id: '3' })
|
|
// reset should be keyed by parameter
|
|
api.getData.reset('2')
|
|
expect(api.getData.current('2')).toBeUndefined()
|
|
expect(api.getData.current('1')).toEqual({ id: '3' })
|
|
expect(api.getData.current('3')).toBeUndefined()
|
|
})
|
|
|
|
it('reset should return to placeholder data', async () => {
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: (id: string) => {
|
|
return Promise.resolve({ id })
|
|
},
|
|
placeholderData: { id: 'initial' },
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(api.getData.current('1')).toEqual({ id: 'initial' })
|
|
expect(api.getData.current('2')).toEqual({ id: 'initial' })
|
|
expect(api.getData.current('3')).toEqual({ id: 'initial' })
|
|
await api.getData.fetch('1')
|
|
await api.getData.fetch('3')
|
|
expect(api.getData.current('1')).toEqual({ id: '1' })
|
|
expect(api.getData.current('2')).toEqual({ id: 'initial' })
|
|
expect(api.getData.current('3')).toEqual({ id: '3' })
|
|
api.getData.reset('1')
|
|
expect(api.getData.current('1')).toEqual({ id: 'initial' })
|
|
expect(api.getData.current('2')).toEqual({ id: 'initial' })
|
|
expect(api.getData.current('3')).toEqual({ id: '3' })
|
|
})
|
|
|
|
it('invalidate should cause a re-fetch', async () => {
|
|
const mockedQuery = jest.fn((id: string) => {
|
|
return Promise.resolve({ id })
|
|
})
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: mockedQuery,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Should not initially fetch
|
|
expect(mockedQuery).not.toHaveBeenCalled()
|
|
expect(api.getData.current('1')).toBeUndefined()
|
|
await api.getData.fetch('1')
|
|
expect(api.getData.current('1')).toEqual({ id: '1' })
|
|
await api.getData.fetch('1')
|
|
await api.getData.fetch('1')
|
|
expect(mockedQuery).toHaveBeenCalledWith('1')
|
|
expect(mockedQuery).toHaveBeenCalledTimes(1)
|
|
|
|
// Invalidate triggers an immediate refetch (refetchType: 'all')
|
|
api.getData.invalidate('1')
|
|
// Wait for the async refetch to complete
|
|
await api.getData.fetch('1')
|
|
expect(mockedQuery).toHaveBeenCalledTimes(2)
|
|
|
|
// Additional fetches don't cause more calls since data is fresh
|
|
await api.getData.fetch('1')
|
|
expect(mockedQuery).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('invalidate should cause automatic re-fetch when useQuery hook is subscribed', async () => {
|
|
let fetchCount = 0
|
|
const mockedQuery = jest.fn((id: string) => {
|
|
fetchCount++
|
|
return Promise.resolve({ id, fetchCount })
|
|
})
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: mockedQuery,
|
|
},
|
|
},
|
|
})
|
|
|
|
function useTestQueryData() {
|
|
const query = api.getData.useQuery('1')
|
|
return query.data
|
|
}
|
|
|
|
// Render the hook which should trigger the initial fetch
|
|
let hookResult = await act(async () => renderHook(useTestQueryData))
|
|
|
|
// Initial fetch should have happened
|
|
expect(mockedQuery).toHaveBeenCalledTimes(1)
|
|
expect(mockedQuery).toHaveBeenCalledWith('1')
|
|
|
|
await act(async () => hookResult.rerender())
|
|
expect(hookResult.result.current).toEqual({ id: '1', fetchCount: 1 })
|
|
|
|
// Invalidate the query while the hook is subscribed
|
|
await act(async () => {
|
|
api.getData.invalidate('1')
|
|
})
|
|
|
|
// Wait for the automatic re-fetch to complete
|
|
await act(async () => hookResult.rerender())
|
|
|
|
// Should have automatically re-fetched due to the active subscription
|
|
expect(mockedQuery).toHaveBeenCalledTimes(2)
|
|
expect(hookResult.result.current).toEqual({ id: '1', fetchCount: 2 })
|
|
})
|
|
|
|
it('supports state that is not queried', async () => {
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
myData: state({
|
|
myValue: 'initial',
|
|
myOtherValue: 2,
|
|
}),
|
|
},
|
|
})
|
|
|
|
expect(api.myData.current()).toEqual({
|
|
myValue: 'initial',
|
|
myOtherValue: 2,
|
|
})
|
|
|
|
// Partial updates do not remove other placeholder properties
|
|
api.myData.update({ myValue: 'updated' })
|
|
|
|
expect(api.myData.current()).toEqual({
|
|
myValue: 'updated',
|
|
myOtherValue: 2,
|
|
})
|
|
})
|
|
|
|
it('creates a hook that returns the query data', async () => {
|
|
const mockedQuery = jest.fn((id: string) => {
|
|
return Promise.resolve({ id })
|
|
})
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: mockedQuery,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Should not initially fetch
|
|
expect(mockedQuery).not.toHaveBeenCalled()
|
|
expect(api.getData.current('1')).toBeUndefined()
|
|
|
|
function useTestQueryData() {
|
|
const query = api.getData.useQuery('1')
|
|
return query.data
|
|
}
|
|
|
|
let hookResult = await act(async () => renderHook(useTestQueryData))
|
|
|
|
// Having the hook should trigger the query immediately
|
|
expect(mockedQuery).toHaveBeenCalledWith('1')
|
|
expect(mockedQuery).toHaveBeenCalledTimes(1)
|
|
expect(api.getData.current('1')).toEqual({ id: '1' })
|
|
// And now the hook should have the data
|
|
await act(async () => hookResult.rerender())
|
|
expect(hookResult.result.current).toEqual({ id: '1' })
|
|
})
|
|
|
|
it('gets array data from a query', async () => {
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: (id: string) => {
|
|
return Promise.resolve({ id })
|
|
},
|
|
},
|
|
getList: {
|
|
query: () => {
|
|
return Promise.resolve([{ id: '1' }, { id: '2' }])
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
function useTestQueryData() {
|
|
const query = api.useGetList()
|
|
return query.data
|
|
}
|
|
|
|
let hookResult = await act(async () => renderHook(useTestQueryData))
|
|
expect(api.getList.current()).toEqual([{ id: '1' }, { id: '2' }])
|
|
await act(async () => hookResult.rerender())
|
|
const dataFromHook = hookResult.result.current
|
|
expect(dataFromHook).toEqual([{ id: '1' }, { id: '2' }])
|
|
// Sanity check that we always get the same reference and not a re-built
|
|
// array. This is important for hook dependencies.
|
|
await act(async () => hookResult.rerender())
|
|
expect(hookResult.result.current).toStrictEqual(dataFromHook)
|
|
})
|
|
|
|
it('gets array data from a query with placeholder', async () => {
|
|
// Allow the test to control return timing
|
|
let resolveGetList: Function = () => {}
|
|
const canReturnGetList = new Promise((resolve) => {
|
|
resolveGetList = resolve
|
|
})
|
|
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: (id: string) => {
|
|
return Promise.resolve({ id })
|
|
},
|
|
},
|
|
getList: {
|
|
query: async () => {
|
|
await canReturnGetList
|
|
return [{ id: '1' }, { id: '2' }]
|
|
},
|
|
placeholderData: [] as { id: string }[],
|
|
},
|
|
},
|
|
})
|
|
|
|
// Generates the convenience function since we define placeholderData
|
|
expect(api.useGetListData).not.toBeUndefined()
|
|
|
|
function useTestQueryData() {
|
|
return api.useGetList().getListData
|
|
}
|
|
|
|
// Verify placeholder data is returned before query finishes
|
|
let hookResult = await act(async () => renderHook(useTestQueryData))
|
|
expect(hookResult.result.current).toEqual([])
|
|
expect(api.getList.current()).toEqual([])
|
|
|
|
// Verify real data
|
|
resolveGetList()
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(api.getList.current()).toEqual([{ id: '1' }, { id: '2' }])
|
|
await act(() => hookResult.rerender())
|
|
expect(hookResult.result.current).toEqual([{ id: '1' }, { id: '2' }])
|
|
})
|
|
|
|
it('can create mutations', async () => {
|
|
const mutationFn = jest.fn((isThing: boolean) => {
|
|
return Promise.resolve({ isThing })
|
|
})
|
|
|
|
const globalOnMutateObserver = jest.fn()
|
|
const hookOnMutateObserver = jest.fn()
|
|
const callOnMutateObserver = jest.fn()
|
|
|
|
function createMyApi() {
|
|
const api = createInterfaceApi({
|
|
endpoints: {
|
|
doSomething: {
|
|
mutation: (isThing: boolean): Promise<{ isThing: boolean }> =>
|
|
mutationFn(isThing),
|
|
onMutate(variables) {
|
|
globalOnMutateObserver(variables)
|
|
},
|
|
},
|
|
},
|
|
actions: {},
|
|
})
|
|
return api
|
|
}
|
|
|
|
const api = createMyApi()
|
|
|
|
expect(api.useDoSomething).toBeDefined()
|
|
expect(typeof api.doSomething).toBe('function')
|
|
|
|
// @ts-expect-error - non-mutation query properties current is not defined for mutations
|
|
expect(api.doSomething.current).toBeUndefined()
|
|
|
|
function useMutate() {
|
|
const result = api.useDoSomething({
|
|
// @ts-expect-error - verify defining a handler here does not work or prevent
|
|
// global event handler from firing
|
|
onSettled(result, error, variables) {
|
|
hookOnMutateObserver(variables)
|
|
},
|
|
})
|
|
return result
|
|
}
|
|
|
|
const hookResult = await renderHook(useMutate)
|
|
|
|
// Should not be called when only rendered
|
|
expect(mutationFn).not.toHaveBeenCalled()
|
|
expect(globalOnMutateObserver).not.toHaveBeenCalled()
|
|
|
|
// Perform mutation side-effect
|
|
await act(async () =>
|
|
hookResult.result.current.doSomething([true], {
|
|
// Define a local event handler
|
|
onSettled(result, error, input) {
|
|
callOnMutateObserver(input, result)
|
|
},
|
|
}),
|
|
)
|
|
|
|
// Verify the hook gets the updated data when re-rendered. It might be available
|
|
// earlier but it definitely should be on re-render given our "fetch" returns
|
|
// immediately.
|
|
await act(async () => hookResult.rerender())
|
|
expect(hookResult.result.current.data).toEqual({ isThing: true })
|
|
|
|
// Verify we used our mutation function with the expected args
|
|
expect(mutationFn).toHaveBeenCalledTimes(1)
|
|
expect(mutationFn).toHaveBeenCalledWith(true)
|
|
|
|
// Verify the global and local event handlers
|
|
expect(globalOnMutateObserver).toHaveBeenCalledWith([true])
|
|
expect(callOnMutateObserver).toHaveBeenCalledWith([true], { isThing: true })
|
|
expect(hookOnMutateObserver).not.toHaveBeenCalled()
|
|
|
|
// Verify direct mutation (not a react hook)
|
|
{
|
|
globalOnMutateObserver.mockClear()
|
|
callOnMutateObserver.mockClear()
|
|
mutationFn.mockClear()
|
|
const directResult = await api.doSomething([true], {
|
|
onSettled(data, error, variables, context) {
|
|
callOnMutateObserver(variables, data)
|
|
},
|
|
})
|
|
expect(directResult).toEqual({ isThing: true })
|
|
expect(globalOnMutateObserver).toHaveBeenCalledWith([true])
|
|
expect(callOnMutateObserver).toHaveBeenCalledWith([true], {
|
|
isThing: true,
|
|
})
|
|
expect(mutationFn).toHaveBeenCalledWith(true)
|
|
}
|
|
|
|
{
|
|
globalOnMutateObserver.mockClear()
|
|
callOnMutateObserver.mockClear()
|
|
mutationFn.mockClear()
|
|
const directResult = await api.doSomething([false], {
|
|
onSettled(data, error, variables, context) {
|
|
callOnMutateObserver(variables, data)
|
|
},
|
|
})
|
|
expect(directResult).toEqual({ isThing: false })
|
|
expect(globalOnMutateObserver).toHaveBeenCalledWith([false])
|
|
expect(callOnMutateObserver).toHaveBeenCalledWith([false], {
|
|
isThing: false,
|
|
})
|
|
expect(mutationFn).toHaveBeenCalledWith(false)
|
|
}
|
|
})
|
|
|
|
it('can create void mutations with no parameters', async () => {
|
|
const mutationFn = jest.fn(() => {
|
|
return Promise.resolve()
|
|
})
|
|
|
|
function createMyApi() {
|
|
const api = createInterfaceApi({
|
|
endpoints: {
|
|
doSomething: {
|
|
mutation: () => {
|
|
return mutationFn()
|
|
},
|
|
},
|
|
},
|
|
actions: {},
|
|
})
|
|
return api
|
|
}
|
|
|
|
const api = createMyApi()
|
|
expect(api.doSomething).toBeDefined()
|
|
expect(api.useDoSomething).toBeDefined()
|
|
// @ts-expect-error - current is not defined for mutations
|
|
expect(api.doSomething.current).toBeUndefined()
|
|
expect(mutationFn).toHaveBeenCalledTimes(0)
|
|
const result = await api.doSomething()
|
|
expect(result).toBeUndefined()
|
|
expect(mutationFn).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('isPending stays true while any concurrent mutateAsync is in flight', async () => {
|
|
// Allow the test to control when each mutation resolves.
|
|
const resolvers = new Map<number, (value: number) => void>()
|
|
const mutationFn = jest.fn((id: number) => {
|
|
return new Promise<number>((resolve) => {
|
|
resolvers.set(id, resolve)
|
|
})
|
|
})
|
|
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
doSomething: {
|
|
mutation: mutationFn,
|
|
},
|
|
},
|
|
})
|
|
|
|
const hookResult = await act(async () =>
|
|
renderHook(() => api.useDoSomething()),
|
|
)
|
|
|
|
expect(hookResult.result.current.isPending).toBe(false)
|
|
|
|
// Fire two mutations without awaiting their completion.
|
|
let p1: Promise<number> = Promise.resolve(0)
|
|
act(() => {
|
|
p1 = hookResult.result.current.mutateAsync([1])
|
|
})
|
|
await waitFor(() =>
|
|
expect(hookResult.result.current.isPending).toBe(true),
|
|
)
|
|
|
|
let p2: Promise<number> = Promise.resolve(0)
|
|
act(() => {
|
|
p2 = hookResult.result.current.mutateAsync([2])
|
|
})
|
|
await waitFor(() =>
|
|
expect(hookResult.result.current.isPending).toBe(true),
|
|
)
|
|
|
|
// Resolve the SECOND (later) mutation first.
|
|
await act(async () => {
|
|
resolvers.get(2)!(2)
|
|
await p2
|
|
})
|
|
|
|
// The first mutation is still in flight, so isPending should remain true.
|
|
expect(hookResult.result.current.isPending).toBe(true)
|
|
|
|
// Resolve the first mutation; now nothing is in flight.
|
|
await act(async () => {
|
|
resolvers.get(1)!(1)
|
|
await p1
|
|
})
|
|
await waitFor(() =>
|
|
expect(hookResult.result.current.isPending).toBe(false),
|
|
)
|
|
})
|
|
|
|
it('accepts mutations defined via endpointsFor under strict function types', async () => {
|
|
// Regression: previously, the `RawEndpoints` index signature used
|
|
// `EndpointDef<any[], any>`, which made tuple-typed mutations like
|
|
// `MutationEndpointDefinition<[Uint8Array, string], …>` fail assignability
|
|
// under `strictFunctionTypes` because of contravariant `onMutate(variables: P)`
|
|
// callbacks. The signature now uses `EndpointDef<any, any>` to side-step
|
|
// that.
|
|
type UploadedFile = { name: string }
|
|
type MyInterface = {
|
|
processImageFile: (
|
|
fileData: number[],
|
|
filename: string,
|
|
) => Promise<{ processedFile: UploadedFile | null }>
|
|
processPdfFile: (
|
|
fileData: number[],
|
|
filename: string,
|
|
) => Promise<{ processedFile: UploadedFile | null }>
|
|
}
|
|
const impl: MyInterface = {
|
|
processImageFile: (_d, n) =>
|
|
Promise.resolve({ processedFile: { name: n } }),
|
|
processPdfFile: (_d, n) =>
|
|
Promise.resolve({ processedFile: { name: n } }),
|
|
}
|
|
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
...endpointsFor(impl, {
|
|
processImageFile: {
|
|
mutationResponse: (result) => result.processedFile,
|
|
},
|
|
processPdfFile: {
|
|
mutationResponse: (result) => result.processedFile,
|
|
},
|
|
}),
|
|
},
|
|
})
|
|
|
|
const result = await api.processImageFile([[1, 2, 3], 'image.png'])
|
|
expect(result).toEqual({ name: 'image.png' })
|
|
})
|
|
|
|
it('can create void mutations that have a parameter', async () => {
|
|
const mutationFn = jest.fn((hi: string) => {
|
|
return Promise.resolve()
|
|
})
|
|
|
|
function createMyApi() {
|
|
const api = createInterfaceApi({
|
|
endpoints: {
|
|
doSomething: {
|
|
mutation: (hi: string) => {
|
|
return mutationFn(hi)
|
|
},
|
|
},
|
|
},
|
|
actions: {},
|
|
})
|
|
return api
|
|
}
|
|
|
|
const api = createMyApi()
|
|
expect(api.doSomething).toBeDefined()
|
|
expect(typeof api.doSomething).toBe('function')
|
|
expect(api.doSomething).toBeDefined()
|
|
// expect(api.useDoSomething).toBeUndefined()
|
|
expect(mutationFn).toHaveBeenCalledTimes(0)
|
|
const result = await api.doSomething(['4'])
|
|
expect(result).toEqual(undefined)
|
|
expect(mutationFn).toHaveBeenCalledTimes(1)
|
|
expect(mutationFn).toHaveBeenCalledWith('4')
|
|
})
|
|
|
|
it('creates actions', () => {
|
|
const doSomething = jest.fn((hi: string) => {})
|
|
function createMyApi() {
|
|
const api = createInterfaceApi({
|
|
endpoints: {},
|
|
actions: {
|
|
doSomething(hi: string) {
|
|
return doSomething(hi)
|
|
},
|
|
moreThings: {
|
|
doAnotherThing() {},
|
|
},
|
|
},
|
|
})
|
|
return api
|
|
}
|
|
const api = createMyApi()
|
|
|
|
expect(api.doSomething).toBeDefined()
|
|
expect(api.moreThings.doAnotherThing).toBeDefined()
|
|
expect(api.doSomething('4')).toBeUndefined()
|
|
expect(doSomething).toHaveBeenCalledWith('4')
|
|
expect(doSomething).toHaveBeenCalledTimes(1)
|
|
expect(api.moreThings.doAnotherThing()).toBeUndefined()
|
|
})
|
|
|
|
it('update does not prevent in-progress fetch', async () => {
|
|
// We need to wrap with endpointsFor in this test only so that
|
|
// prefetchWithArgs doesn't interfere with the QArgs type inference.
|
|
type MyInterface = {
|
|
getData(): Promise<{ id: string; anotherProperty: string }>
|
|
}
|
|
|
|
let allowGetDataToResolve = (_?: unknown) => {}
|
|
|
|
const getDataObserver = jest.fn()
|
|
|
|
const impl: MyInterface = {
|
|
getData: async () => {
|
|
getDataObserver()
|
|
await new Promise((resolve) => (allowGetDataToResolve = resolve))
|
|
return Promise.resolve({ id: '1', anotherProperty: 'fetched' })
|
|
},
|
|
}
|
|
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: endpointsFor(impl, {
|
|
getData: {
|
|
response: (result) => result,
|
|
prefetchWithArgs: [],
|
|
placeholderData: {
|
|
id: '0',
|
|
anotherProperty: 'placeholder',
|
|
},
|
|
},
|
|
}),
|
|
})
|
|
|
|
// prefetch has happened
|
|
expect(getDataObserver).toHaveBeenCalled()
|
|
|
|
// initially placeholder data
|
|
expect(api.getData.current()).not.toBeUndefined()
|
|
expect(api.getData.current()).toEqual({
|
|
id: '0',
|
|
anotherProperty: 'placeholder',
|
|
})
|
|
|
|
// make a partial update
|
|
api.getData.update({ anotherProperty: 'update' })
|
|
|
|
// verify other properties still intact
|
|
expect(api.getData.current()).toEqual({
|
|
id: '0',
|
|
anotherProperty: 'update',
|
|
})
|
|
|
|
// when initial fetch returns, it replaces the update
|
|
allowGetDataToResolve()
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(api.getData.current()).toEqual({
|
|
id: '1',
|
|
anotherProperty: 'fetched',
|
|
})
|
|
})
|
|
|
|
it('updates the query cache when update is called', async () => {
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getData: {
|
|
query: (id: string) => {
|
|
return Promise.resolve({ id })
|
|
},
|
|
},
|
|
getList: {
|
|
query: () => {
|
|
return Promise.resolve([{ id: '1' }, { id: '2' }])
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// initially undefined
|
|
expect(api.getList.current()).toBeUndefined()
|
|
expect(api.getData.current('1')).toBeUndefined()
|
|
|
|
// fetch
|
|
await api.getList.fetch()
|
|
await api.getData.fetch('1')
|
|
// returned data matches
|
|
expect(api.getList.current()).toEqual([{ id: '1' }, { id: '2' }])
|
|
expect(api.getData.current('1')).toEqual({ id: '1' })
|
|
|
|
// update object
|
|
api.getData.update('1', { id: '3' })
|
|
expect(api.getData.current('1')).toEqual({ id: '3' })
|
|
|
|
// update array
|
|
api.getList.update([{ id: '3' }, { id: '4' }])
|
|
// check the array has not been spread to an object during the update()
|
|
expect(api.getList.current()).toEqual([{ id: '3' }, { id: '4' }])
|
|
})
|
|
|
|
it('creates state queries with placeholder data', async () => {
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
state: state({
|
|
aProp: 'initial',
|
|
bProp: true,
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Ensure typescript knows, and actual state is, never undefined since we
|
|
// provide initial data.
|
|
expect(api.state.current().aProp).not.toBeUndefined()
|
|
expect(api.state.current()).toEqual({ aProp: 'initial', bProp: true })
|
|
|
|
// Render a hook and verify the query is not fetched (no error)
|
|
function useStateData() {
|
|
return api.state.useQuery()
|
|
}
|
|
|
|
let hookResult = await act(async () => renderHook(useStateData))
|
|
|
|
expect(hookResult.result.current.data).toEqual({
|
|
aProp: 'initial',
|
|
bProp: true,
|
|
})
|
|
expect(hookResult.result.current.error).toBeNull()
|
|
|
|
await act(async () => hookResult.rerender())
|
|
|
|
expect(hookResult.result.current.data).toEqual({
|
|
aProp: 'initial',
|
|
bProp: true,
|
|
})
|
|
expect(hookResult.result.current.error).toBeNull()
|
|
})
|
|
|
|
it('creates state queries with no placeholder data', async () => {
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
flag: state<boolean>(),
|
|
},
|
|
})
|
|
|
|
// Ensure typescript knows, and actual state is, undefined when we don't
|
|
// provide initial data.
|
|
expect(api.flag.current()).toBeUndefined()
|
|
// Ensure typescript knows, and actual state is, undefined when we don't
|
|
// provide initial data.
|
|
expect(api.flag.current()).toBeUndefined()
|
|
|
|
// Render a hook and verify the query is not fetched (no error)
|
|
function useStateData() {
|
|
return api.flag.useQuery()
|
|
}
|
|
|
|
let hookResult = await act(async () => renderHook(useStateData))
|
|
|
|
expect(hookResult.result.current.data).toBeUndefined()
|
|
expect(hookResult.result.current.error).toBeNull()
|
|
|
|
await act(async () => hookResult.rerender())
|
|
|
|
expect(hookResult.result.current.data).toBeUndefined()
|
|
expect(hookResult.result.current.error).toBeNull()
|
|
|
|
// set data
|
|
api.flag.update(true)
|
|
|
|
await act(async () => hookResult.rerender())
|
|
|
|
expect(hookResult.result.current.data).toBe(true)
|
|
expect(hookResult.result.current.error).toBeNull()
|
|
expect(api.flag.current()).toBe(true)
|
|
})
|
|
|
|
it('hook should update when api.update() is called with array placeholder data', async () => {
|
|
// This test replicates the conversation history scenario:
|
|
// - Query has placeholder data (empty array)
|
|
// - Data is updated via update() (from Mojo events)
|
|
// - Hook should reflect the updated data
|
|
let fetchCount = 0
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getHistory: {
|
|
query: () => {
|
|
fetchCount++
|
|
return Promise.resolve([{ id: `${fetchCount}` }])
|
|
},
|
|
placeholderData: [] as { id: string }[],
|
|
},
|
|
},
|
|
})
|
|
|
|
function useHistoryData() {
|
|
const query = api.useGetHistory()
|
|
return {
|
|
data: query.data,
|
|
dataUpdatedAt: query.dataUpdatedAt,
|
|
isPlaceholderData: query.isPlaceholderData,
|
|
}
|
|
}
|
|
|
|
// Render the hook
|
|
let hookResult = await act(async () => renderHook(useHistoryData))
|
|
|
|
// Wait for prefetch to complete
|
|
await act(async () => hookResult.rerender())
|
|
|
|
// Should have fetched data
|
|
expect(fetchCount).toBe(1)
|
|
expect(hookResult.result.current.data).toEqual([{ id: '1' }])
|
|
expect(hookResult.result.current.isPlaceholderData).toBe(false)
|
|
|
|
// Now simulate a Mojo event updating the data via update()
|
|
await act(async () => {
|
|
api.getHistory.update((old) => [...old, { id: '2' }])
|
|
})
|
|
|
|
await act(async () => hookResult.rerender())
|
|
|
|
// Hook should see the updated data
|
|
expect(hookResult.result.current.data).toEqual([{ id: '1' }, { id: '2' }])
|
|
})
|
|
|
|
it('hook should update when api changes (simulating conversation switch)', async () => {
|
|
// This test replicates the scenario where:
|
|
// - API #1 is created
|
|
// - Hook subscribes to API #1
|
|
// - API #2 is created
|
|
// - Hook should now use API #2's data via different query keys
|
|
//
|
|
// With shared QueryClient, unique keys ensure data isolation but intact
|
|
// subscription to the correct instance.
|
|
|
|
let canReturn: Function = () => {}
|
|
|
|
function createTestApi(key: string) {
|
|
return createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {
|
|
getHistory: {
|
|
query: () =>
|
|
new Promise<{ id: string }[]>((resolve) => {
|
|
// Allow the test to control when the query resolves to avoid
|
|
// flakey timing issues.
|
|
canReturn = () => resolve([{ id: key }])
|
|
}),
|
|
placeholderData: [] as { id: string }[],
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
let currentApi = createTestApi('1')
|
|
|
|
function useHistoryData() {
|
|
const query = currentApi.useGetHistory()
|
|
return {
|
|
data: query.data,
|
|
status: query.status,
|
|
fetchStatus: query.fetchStatus,
|
|
isPlaceholderData: query.isPlaceholderData,
|
|
}
|
|
}
|
|
|
|
// Use the shared APIQueryClientProvider wrapper
|
|
// Render with API #1
|
|
let hookResult: RenderHookResult<
|
|
ReturnType<typeof useHistoryData>,
|
|
void
|
|
> = await act(async () => renderHook(useHistoryData))
|
|
canReturn()
|
|
await act(async () => hookResult.rerender())
|
|
|
|
expect(hookResult.result.current.data).toEqual([{ id: '1' }])
|
|
|
|
// Switch to API #2 (simulating bindConversation)
|
|
// This creates a new API with different key, so hooks use different query keys
|
|
currentApi = createTestApi('2')
|
|
|
|
// Re-render - the hook now uses the new API's query key
|
|
await act(async () => hookResult.rerender())
|
|
// First render will be placeholder data
|
|
expect(hookResult.result.current.data).toEqual([])
|
|
canReturn()
|
|
// Second render should be fetched data
|
|
await act(async () => hookResult.rerender())
|
|
// With shared QueryClient and unique keys, hooks use the correct data
|
|
expect(hookResult.result.current.data).toEqual([{ id: '2' }])
|
|
|
|
// Now update API #2's data via update() (simulating Mojo event)
|
|
await act(async () => {
|
|
currentApi.getHistory.update([{ id: '2' }, { id: '3' }])
|
|
})
|
|
|
|
await act(async () => hookResult.rerender())
|
|
|
|
expect(hookResult.result.current.data).toEqual([{ id: '2' }, { id: '3' }])
|
|
})
|
|
|
|
it('fires and handles events', () => {
|
|
let emitter: (...args: [string, number]) => void = () => {}
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {},
|
|
events: {
|
|
myEvent: event<[], [string, number]>((emit) => {
|
|
emitter = emit
|
|
}),
|
|
},
|
|
})
|
|
|
|
const observer = jest.fn()
|
|
const unsubscribe = api.subscribeToMyEvent(observer)
|
|
|
|
// manually call the event
|
|
api.emitEvent('myEvent', ['4', 4])
|
|
|
|
expect(observer).toHaveBeenCalled()
|
|
expect(observer).toHaveBeenCalledWith('4', 4)
|
|
|
|
// call the event from some outside function
|
|
emitter('5', 5)
|
|
expect(observer).toHaveBeenCalledTimes(2)
|
|
expect(observer).toHaveBeenLastCalledWith('5', 5)
|
|
|
|
// verify unsubscribe no longer gets notifications
|
|
unsubscribe()
|
|
api.emitEvent('myEvent', ['6', 6])
|
|
expect(observer).toHaveBeenCalledTimes(2)
|
|
emitter('5', 5)
|
|
expect(observer).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('fires and handles same-argument events', () => {
|
|
let emitter: (...args: []) => void = () => {}
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {},
|
|
events: {
|
|
myVoidEvent: event<[], []>((emit) => {
|
|
emitter = emit
|
|
}),
|
|
},
|
|
})
|
|
|
|
const observer = jest.fn()
|
|
const unsubscribe = api.subscribeToMyVoidEvent(observer)
|
|
|
|
// manually call the event
|
|
api.emitEvent('myVoidEvent', [])
|
|
api.emitEvent('myVoidEvent', [])
|
|
api.emitEvent('myVoidEvent', [])
|
|
|
|
expect(observer).toHaveBeenCalled()
|
|
|
|
// call the event from some outside function
|
|
expect(observer).toHaveBeenCalledTimes(3)
|
|
|
|
// verify unsubscribe no longer gets notifications
|
|
unsubscribe()
|
|
api.emitEvent('myVoidEvent', [])
|
|
expect(observer).toHaveBeenCalledTimes(3)
|
|
emitter()
|
|
expect(observer).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('updates the event data hook when an event is emitted', async () => {
|
|
let emitter: (...args: [string, number]) => void = () => {}
|
|
const api = createInterfaceApi({
|
|
actions: {},
|
|
endpoints: {},
|
|
events: {
|
|
myEvent: event<[], [string, number]>((emit) => {
|
|
emitter = emit
|
|
}),
|
|
},
|
|
})
|
|
|
|
// Render a hook that subscribes to the latest event data
|
|
function useMyEventData() {
|
|
return api.useCurrentMyEvent()
|
|
}
|
|
let hookResult: RenderHookResult<
|
|
ReturnType<typeof useMyEventData>,
|
|
void
|
|
> = await act(async () => renderHook(useMyEventData))
|
|
|
|
expect(hookResult.result.current.hasEmitted).toBe(false)
|
|
expect(hookResult.result.current.data).toBeUndefined()
|
|
|
|
// manually call the event
|
|
api.emitEvent('myEvent', ['4', 4])
|
|
|
|
// hook should be subscribed and get latest data
|
|
await act(async () => hookResult.rerender())
|
|
expect(hookResult.result.current.hasEmitted).toBe(true)
|
|
expect(hookResult.result.current.data).toEqual(['4', 4])
|
|
|
|
// reset should be as-if the event hasn't emitted
|
|
api.resetMyEvent()
|
|
await act(async () => hookResult.rerender())
|
|
await act(async () => hookResult.rerender())
|
|
await act(async () => hookResult.rerender())
|
|
expect(hookResult.result.current.hasEmitted).toBe(false)
|
|
expect(hookResult.result.current.data).toBeUndefined()
|
|
|
|
// call the event from some outside function
|
|
emitter('5', 5)
|
|
|
|
// emitter should also cause the hook to update
|
|
await act(async () => hookResult.rerender())
|
|
expect(hookResult.result.current.hasEmitted).toBe(true)
|
|
expect(hookResult.result.current.data).toEqual(['5', 5])
|
|
|
|
// Another component mounting should also see the latest event data
|
|
let secondHookResult: RenderHookResult<
|
|
ReturnType<typeof useMyEventData>,
|
|
void
|
|
> = await act(async () => renderHook(useMyEventData))
|
|
expect(secondHookResult.result.current.data).toEqual(['5', 5])
|
|
expect(secondHookResult.result.current.hasEmitted).toBe(true)
|
|
})
|
|
})
|