@marklogic-community/grove-search-redux
Version:
Redux module to power MarkLogic search UIs
414 lines (394 loc) • 13.4 kB
JavaScript
/* eslint-env jest */
import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import nock from 'nock';
import reducer, { actions, selectors } from './index';
import { mockResults, mockFacets, mockSearchResponse } from './test-helpers';
describe('search', () => {
let store;
beforeEach(() => {
store = createStore(reducer, applyMiddleware(thunk));
});
describe('page', () => {
it('is initially undefined, defaults to 1 on search, and changes', () => {
expect(selectors.getPage(store.getState())).toBeUndefined();
expect(selectors.getPageLength(store.getState())).toBeUndefined();
const mockAPI = { search: () => Promise.resolve({}) };
store.dispatch(
actions.runSearch(selectors.getStagedQuery(store.getState()), {
api: mockAPI
})
);
expect(selectors.getPage(store.getState())).toEqual(1);
expect(selectors.getPageLength(store.getState())).toEqual(10);
store.dispatch(actions.changePage(3));
store.dispatch(
actions.runSearch(selectors.getStagedQuery(store.getState()), {
api: mockAPI
})
);
expect(selectors.getPage(store.getState())).toEqual(3);
});
});
it('manages filters', () => {
expect(selectors.stagedFilters(store.getState())).toEqual([]);
// TODO: validation that it is valid and not duplicate? Where?
// Probably not duplicate in reducer, right? A NOOP?
store.dispatch(actions.addFilter('eyeColor', null, 'blue'));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'eyeColor',
mode: 'and',
value: ['blue']
}
]);
store.dispatch(actions.addFilter('eyeColor', null, ['brown', 'red']));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'eyeColor',
mode: 'and',
value: ['blue', 'brown', 'red']
}
]);
store.dispatch(actions.removeFilter('eyeColor', 'brown'));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'eyeColor',
mode: 'and',
value: ['blue', 'red']
}
]);
store.dispatch(actions.addFilter('gender', null, 'female'));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'eyeColor',
mode: 'and',
value: ['blue', 'red']
},
{
type: 'selection',
constraint: 'gender',
mode: 'and',
value: ['female']
}
]);
store.dispatch(actions.removeFilter('eyeColor', ['blue', 'red']));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'gender',
mode: 'and',
value: ['female']
}
]);
});
it('manages filters with object values', () => {
const box1 = {
south: 38,
north: 42,
east: 10,
west: 20
};
const box2 = {
south: -38,
north: -42,
east: -10,
west: -20
};
store.dispatch(actions.addFilter('location', 'geospatial', box1));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'location',
constraintType: 'geospatial',
mode: 'and',
value: [box1]
}
]);
store.dispatch(actions.addFilter('location', null, box2));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'location',
constraintType: 'geospatial',
mode: 'and',
value: [box1, box2]
}
]);
store.dispatch(actions.removeFilter('location', { ...box1 }));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'location',
constraintType: 'geospatial',
mode: 'and',
value: [box2]
}
]);
});
it('clears filters based on constraint', () => {
store.dispatch(actions.addFilter('eyeColor', null, 'blue'));
store.dispatch(actions.addFilter('eyeColor', null, 'brown'));
store.dispatch(actions.clearFilter('eyeColor'));
expect(selectors.stagedFilters(store.getState())).toEqual([]);
});
it('replaces filters based on constraint and mode', () => {
store.dispatch(actions.addFilter('eyeColor', null, 'blue'));
store.dispatch(actions.addFilter('eyeColor', null, 'brown'));
store.dispatch(
actions.addFilter('eyeColor', null, 'or-color', { boolean: 'or' })
);
store.dispatch(actions.replaceFilter('eyeColor', null, ['orange']));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'eyeColor',
mode: 'and',
value: ['orange']
},
{
type: 'selection',
constraint: 'eyeColor',
mode: 'or',
value: ['or-color']
}
]);
store.dispatch(
actions.replaceFilter('eyeColor', null, ['green', 'red'], {
boolean: 'or'
})
);
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'eyeColor',
mode: 'and',
value: ['orange']
},
{
type: 'selection',
constraint: 'eyeColor',
mode: 'or',
value: ['green', 'red']
}
]);
});
it('manages ORed filters', () => {
store.dispatch(
actions.addFilter('eyeColor', null, 'blue', { boolean: 'or' })
);
store.dispatch(
actions.addFilter('eyeColor', null, 'brown', { boolean: 'or' })
);
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'eyeColor',
mode: 'or',
value: ['blue', 'brown']
}
]);
store.dispatch(actions.removeFilter('eyeColor', 'blue', { boolean: 'or' }));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'eyeColor',
mode: 'or',
value: ['brown']
}
]);
store.dispatch(
actions.removeFilter('eyeColor', 'brown', { boolean: 'or' })
);
expect(selectors.stagedFilters(store.getState())).toEqual([]);
});
// TODO: deprecate, see GROVE-276; this is a leaky abstraction
it('passes through constraintType', () => {
store.dispatch(actions.addFilter('gender', 'collection', 'female'));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'gender',
constraintType: 'collection',
mode: 'and',
value: ['female']
}
]);
store.dispatch(actions.removeFilter('gender', 'female'));
store.dispatch(actions.addFilter('gender', 'xs:string', 'female'));
expect(selectors.stagedFilters(store.getState())).toEqual([
{
type: 'selection',
constraint: 'gender',
constraintType: 'range',
mode: 'and',
value: ['female']
}
]);
});
describe('runSearch', () => {
afterEach(nock.cleanAll);
it('runs a successful search', done => {
nock('http://localhost')
.post('/api/search/all')
.reply(200, mockSearchResponse);
expect(selectors.getSearchResults(store.getState())).toEqual([]);
expect(selectors.isSearchPending(store.getState())).toBe(false);
const unsubscribe = store.subscribe(() => {
try {
expect(selectors.isSearchPending(store.getState())).toBe(true);
expect(selectors.getSearchResults(store.getState())).toEqual([]);
// Page defaults
expect(selectors.getPage(store.getState())).toEqual(1);
expect(selectors.getPageLength(store.getState())).toEqual(10);
} catch (error) {
done.fail(error);
}
unsubscribe();
});
store
.dispatch(actions.runSearch(selectors.getStagedQuery(store.getState())))
.then(() => {
try {
expect(nock.isDone()).toBe(true);
expect(selectors.isSearchPending(store.getState())).toBe(false);
expect(selectors.getSearchResults(store.getState())).toEqual(
mockResults
);
expect(selectors.searchFacets(store.getState())).toEqual(
mockFacets
);
expect(selectors.getSearchExecutionTime(store.getState())).toEqual(
0.00198
);
expect(selectors.getSearchTotal(store.getState())).toEqual(101);
expect(selectors.getPage(store.getState())).toEqual(1);
expect(selectors.getPageLength(store.getState())).toEqual(10);
} catch (error) {
done.fail(error);
}
done();
});
});
// TODO: this helps to remove flickering when new searches run
// but it is a little odd logically
// This might suggest that current results and facets should
// exist outside the 'executedSearch'
it('maintains stale results while new search pending', done => {
nock('http://localhost')
.persist()
.post('/api/search/all')
.reply(200, mockSearchResponse);
store
.dispatch(actions.runSearch(selectors.getStagedQuery(store.getState())))
.then(() => {
expect(nock.isDone()).toBe(true);
const unsubscribe = store.subscribe(() => {
try {
expect(selectors.isSearchPending(store.getState())).toBe(true);
expect(selectors.getSearchResults(store.getState())).toEqual(
mockResults
);
done();
} catch (error) {
done.fail(error);
}
unsubscribe();
});
})
.then(() =>
store.dispatch(
actions.runSearch(selectors.getStagedQuery(store.getState()))
)
);
});
it('responds to queryText changes', done => {
const mockSearch = jest.fn(() => Promise.resolve({}));
const mockAPI = { search: mockSearch };
expect(selectors.getVisibleQueryText(store.getState())).toEqual('');
store.dispatch(actions.setQueryText('new text'));
expect(selectors.getVisibleQueryText(store.getState())).toEqual(
'new text'
);
store
.dispatch(
actions.runSearch(selectors.getStagedQuery(store.getState()), {
api: mockAPI
})
)
.then(() => {
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ queryText: 'new text' }),
expect.anything()
);
expect(
selectors.getExecutedSearchQueryText(store.getState())
).toEqual('new text');
done();
});
});
it('can paginate', done => {
const mockSearch = jest.fn(() => Promise.resolve({}));
const mockAPI = { search: mockSearch };
store.dispatch(actions.changePage(2));
store
.dispatch(
actions.runSearch(selectors.getStagedQuery(store.getState()), {
api: mockAPI
})
)
.then(() => {
expect(selectors.getPage(store.getState())).toEqual(2);
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({ options: { pageLength: 10, start: 11 } }),
expect.anything()
);
done();
});
});
it('reports error after search failure', done => {
nock('http://localhost')
.post('/api/search/all')
.reply(400, {
statusCode: 400,
status: 'Bad Request',
message: 'REST-INVALIDTYPE: (rest:INVALIDTYPE) Invalid type',
messageCode: 'REST-INVALIDTYPE'
});
expect(selectors.getSearchError(store.getState())).toBeUndefined();
let isFirstUpdate = true;
store.subscribe(() => {
if (isFirstUpdate) {
expect(selectors.getSearchError(store.getState())).toBeUndefined();
isFirstUpdate = false;
} else {
expect(selectors.getSearchError(store.getState())).toEqual(
expect.stringContaining('Invalid type')
);
done();
}
});
store.dispatch(
actions.runSearch(selectors.getStagedQuery(store.getState()))
);
});
it('allows search to be cleared', () => {
store.dispatch(actions.receiveSuccessfulSearch(mockSearchResponse));
expect(selectors.getSearchResults(store.getState())).toEqual(mockResults);
store.dispatch(actions.clearSearchResults());
expect(selectors.getSearchResults(store.getState())).toEqual([]);
expect(selectors.isSearchPending(store.getState())).toBe(false);
expect(selectors.getPage(store.getState())).toBeUndefined();
expect(selectors.getPageLength(store.getState())).toBeUndefined();
expect(selectors.getSearchError(store.getState())).toBeUndefined();
});
// Could have a long-running search error, while a second search succeeds
// Successful results should be maintained and error should not enter state
it('only reacts to the latest executedSearch results');
it('allows for reducer / action namespacing');
});
});