Skip to content

Commit

Permalink
tests: Composition (#6510)
Browse files Browse the repository at this point in the history
  • Loading branch information
e-krebs authored Jan 7, 2025
1 parent a22d8d1 commit ad7535e
Show file tree
Hide file tree
Showing 8 changed files with 457 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {
createCompositionClient,
createSearchClient,
} from '@instantsearch/mocks';
import { castToJestMock } from '@instantsearch/testutils/castToJestMock';
import { wait } from '@instantsearch/testutils/wait';
import originalHelper from 'algoliasearch-helper';

import { connectSearchBox } from '../../connectors';
import { index } from '../../widgets';
import InstantSearch from '../InstantSearch';

type AlgoliaHelperModule = typeof algoliasearchHelper;

const algoliasearchHelper = castToJestMock(originalHelper);
jest.mock('algoliasearch-helper', () => {
const module = jest.requireActual<AlgoliaHelperModule>(
'algoliasearch-helper'
);
const mock = jest.fn((...args: Parameters<AlgoliaHelperModule>) => {
const helper = module(...args);

const searchWithComposition = helper.searchWithComposition.bind(helper);
const searchForCompositionFacetValues =
helper.searchForCompositionFacetValues.bind(helper);

helper.searchWithComposition = jest.fn((...searchArgs) => {
return searchWithComposition(...searchArgs);
});
helper.searchForCompositionFacetValues = jest.fn((...searchArgs) => {
return searchForCompositionFacetValues(...searchArgs);
});

return helper;
});

Object.entries(module).forEach(([key, value]) => {
// @ts-expect-error Object.entries loses type safety
mock[key] = value;
});

return mock;
});

const virtualSearchBox = connectSearchBox(() => {});

beforeEach(() => {
algoliasearchHelper.mockClear();
});

describe('Composition implementation', () => {
describe('root index warning', () => {
it('throws if compositionID & index widget is provided', () => {
expect(() => {
const search = new InstantSearch({
searchClient: createCompositionClient(),
compositionID: 'my-composition',
});

search.addWidgets([index({ indexName: 'indexName' })]);
}).toThrowErrorMatchingInlineSnapshot(`
"The \`index\` widget cannot be used with a composition-based InstantSearch implementation.
See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/"
`);
});

it('does not throw if compositionID is not provided while index widget is provided', () => {
expect(() => {
const search = new InstantSearch({
searchClient: createSearchClient(),
});

search.addWidgets([index({ indexName: 'indexName' })]);
}).not.toThrow();
});
});

it('replaces the regular `searchForFacetValues` with `searchForCompositionFacetValues` if CompositionID is provided', () => {
const searchClient = createCompositionClient();
const search = new InstantSearch({
compositionID: 'my-composition',
searchClient,
});

search.start();

search.mainHelper?.searchForFacetValues('brand', 'algolia', 20);

expect(
search.mainHelper!.searchForCompositionFacetValues
).toHaveBeenNthCalledWith(1, 'brand', 'algolia', 20);
});

it('does not replace the regular `searchForFacetValues` with `searchForCompositionFacetValues` if CompositionID is not provided', () => {
const searchClient = createSearchClient();
const search = new InstantSearch({ searchClient });

search.start();

search.mainHelper?.searchForFacetValues('brand', 'algolia', 20);

expect(
search.mainHelper!.searchForCompositionFacetValues
).not.toHaveBeenCalled();
});

it('replaces the regular `search` with `searchWithComposition` if CompositionID is provided', async () => {
const searchClient = createCompositionClient();
const search = new InstantSearch({
compositionID: 'my-composition',
searchClient,
});

search.addWidgets([virtualSearchBox({})]);
search.start();

await wait(0);

expect(search.mainHelper!.searchWithComposition).toHaveBeenCalledTimes(1);
});
});
25 changes: 0 additions & 25 deletions packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,31 +159,6 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend
'[InstantSearch.js]: No indexName provided, nor an explicit index widget in the widgets tree. This is required to be able to display results.'
);
});

it('throws if compositionID & index widget is provided', () => {
expect(() => {
const search = new InstantSearch({
searchClient: createSearchClient(),
compositionID: 'my-composition',
});

search.addWidgets([index({ indexName: 'indexName' })]);
}).toThrowErrorMatchingInlineSnapshot(`
"The \`index\` widget cannot be used with a composition-based InstantSearch implementation.
See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/"
`);
});

it('does not throw if compositionID is not provided while index widget is provided', () => {
expect(() => {
const search = new InstantSearch({
searchClient: createSearchClient(),
});

search.addWidgets([index({ indexName: 'indexName' })]);
}).not.toThrow();
});
});

it('throws if insightsClient is not a function', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
createCompositionClient,
createControlledCompositionClient,
} from '@instantsearch/mocks';

import { connectConfigure, connectSearchBox } from '../../connectors';
import instantsearch from '../../index.es';
import { waitForResults } from '../server';

describe('waitForResults', () => {
test('waits for the results from the search instance', async () => {
const { searchClient, searches } = createControlledCompositionClient();
const search = instantsearch({
compositionID: 'my-composition',
searchClient,
initialUiState: {
'my-composition': {
query: 'apple',
},
},
}).addWidgets([
connectConfigure(() => {})({ searchParameters: { hitsPerPage: 2 } }),
connectSearchBox(() => {})({}),
]);

search.start();

const output = waitForResults(search);

searches[0].resolver();

await expect(output).resolves.toEqual([
expect.objectContaining({ query: 'apple', hitsPerPage: 2 }),
]);
});

test('throws on a search client error', async () => {
const { searchClient, searches } = createControlledCompositionClient();
const search = instantsearch({
compositionID: 'my-composition',
searchClient,
}).addWidgets([connectSearchBox(() => {})({})]);

search.start();

const output = waitForResults(search);

searches[0].rejecter({ message: 'Search error' });

await expect(output).rejects.toThrowErrorMatchingInlineSnapshot(
`"Search error"`
);
});

test('throws on an InstantSearch error', async () => {
const search = instantsearch({
compositionID: 'my-composition',
searchClient: createCompositionClient(),
}).addWidgets([connectSearchBox(() => {})({})]);

search.start();

const output = waitForResults(search);

search.on('error', () => {});
search.emit('error', new Error('Search error'));

await expect(output).rejects.toThrowErrorMatchingInlineSnapshot(
`"Search error"`
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createCompositionClient } from '@instantsearch/mocks';

import { hydrateSearchClient } from '../hydrateSearchClient';

import type { InitialResults, CompositionClient } from '../../../types';

const setupCompositionClient = () => {
const getCache = jest.fn();
const setCache = jest.fn();
const search: CompositionClient['search'] = jest.fn();
const client: CompositionClient & {
_cacheHydrated?: boolean;
_useCache?: boolean;
cache?: Record<string, string>;
} = createCompositionClient({
transporter: { responsesCache: { set: setCache, get: getCache } },
addAlgoliaAgent: jest.fn(),
search,
});

return { client, getCache, setCache, search };
};

describe('hydrateSearchClient (composition)', () => {
const initialResults = {
instant_search: {
results: [{ params: 'params', nbHits: 1000 }],
state: {},
rawResults: [{ params: 'params', nbHits: 1000 }],
},
} as unknown as InitialResults;

it('should hydrate the client if the cache is enabled and the Algolia agent is present', () => {
const { client, setCache } = setupCompositionClient();

hydrateSearchClient(client, initialResults);

expect(setCache).toHaveBeenCalledTimes(1);
expect(setCache).toHaveBeenCalledWith(
expect.objectContaining({
args: [[{ params: 'params=' }]],
method: 'search',
}),
expect.objectContaining({
results: [{ params: 'params', nbHits: 1000 }],
})
);
expect(client._cacheHydrated).toBe(true);
expect(client.search).toBeDefined();
});

describe('when calling client.search (with composition params)', () => {
it('should call getCache but not client initial search function', () => {
const { client, getCache, search } = setupCompositionClient();

hydrateSearchClient(client, initialResults);

client.search({
compositionID: 'my-composition',
requestBody: { params: { query: 'test' } },
});

expect(getCache).toHaveBeenCalledTimes(1);
expect(getCache).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
args: ['query=test'],
method: 'search',
}),
expect.any(Function)
);
expect(search).not.toHaveBeenCalled();
});

describe('calling cache getter function', () => {
it('should call client initial search function (with composition params)', () => {
const { client, getCache, search } = setupCompositionClient();

hydrateSearchClient(client, initialResults);

expect(search).not.toHaveBeenCalled();

client.search({
compositionID: 'my-composition',
requestBody: { params: { query: 'test' } },
});

expect(getCache).toHaveBeenCalledTimes(1);
expect(getCache).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.any(Function)
);
const cacheGetter = getCache.mock.calls[0][1];

cacheGetter();

expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenNthCalledWith(1, {
compositionID: 'my-composition',
requestBody: { params: { query: 'test' } },
});
});
});
});
});
Loading

0 comments on commit ad7535e

Please sign in to comment.