// 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 void>() const mutationFn = jest.fn((id: number) => { return new Promise((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 = Promise.resolve(0) act(() => { p1 = hookResult.result.current.mutateAsync([1]) }) await waitFor(() => expect(hookResult.result.current.isPending).toBe(true), ) let p2: Promise = 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`, 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` 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(), }, }) // 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, 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, 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, void > = await act(async () => renderHook(useMyEventData)) expect(secondHookResult.result.current.data).toEqual(['5', 5]) expect(secondHookResult.result.current.hasEmitted).toBe(true) }) })