Files
brave-core/components/common/api/create_interface_api.test.tsx
Pete Miller 54238e7b90 [AI Chat] Fix attachment progress not shown (#36583)
* [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
2026-05-22 21:08:59 +00:00

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)
})
})