-
Notifications
You must be signed in to change notification settings - Fork 8.6k
[App Search] Analytics - initial logic file & server API routes #88250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f11b254
6d08cb0
a54fdea
49c6faf
eaed609
4400676
4ab77fc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,308 @@ | ||
| /* | ||
| * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
| * or more contributor license agreements. Licensed under the Elastic License; | ||
| * you may not use this file except in compliance with the Elastic License. | ||
| */ | ||
|
|
||
| import { LogicMounter } from '../../../__mocks__/kea.mock'; | ||
|
|
||
| jest.mock('../../../shared/kibana', () => ({ | ||
| KibanaLogic: { values: { history: { location: { search: '' } } } }, | ||
| })); | ||
| import { KibanaLogic } from '../../../shared/kibana'; | ||
|
|
||
| jest.mock('../../../shared/http', () => ({ | ||
| HttpLogic: { values: { http: { get: jest.fn() } } }, | ||
| })); | ||
| import { HttpLogic } from '../../../shared/http'; | ||
|
|
||
| jest.mock('../../../shared/flash_messages', () => ({ | ||
| flashAPIErrors: jest.fn(), | ||
| })); | ||
| import { flashAPIErrors } from '../../../shared/flash_messages'; | ||
|
|
||
| jest.mock('../engine', () => ({ | ||
| EngineLogic: { values: { engineName: 'test-engine' } }, | ||
| })); | ||
|
|
||
| import { AnalyticsLogic } from './'; | ||
|
|
||
| describe('AnalyticsLogic', () => { | ||
| const DEFAULT_VALUES = { | ||
| dataLoading: true, | ||
| analyticsUnavailable: false, | ||
| }; | ||
|
|
||
| const MOCK_TOP_QUERIES = [ | ||
| { | ||
| doc_count: 5, | ||
| key: 'some-key', | ||
| }, | ||
| { | ||
| doc_count: 0, | ||
| key: 'another-key', | ||
| }, | ||
| ]; | ||
| const MOCK_RECENT_QUERIES = [ | ||
| { | ||
| document_ids: ['1', '2'], | ||
| query_string: 'some-query', | ||
| tags: ['some-tag'], | ||
| timestamp: 'some-timestamp', | ||
| }, | ||
| ]; | ||
| const MOCK_TOP_CLICKS = [ | ||
| { | ||
| key: 'highly-clicked-query', | ||
| doc_count: 1, | ||
| document: { | ||
| id: 'some-id', | ||
| engine: 'some-engine', | ||
| tags: [], | ||
| }, | ||
| clicks: { | ||
| doc_count: 100, | ||
| }, | ||
| }, | ||
| ]; | ||
| const MOCK_ANALYTICS_RESPONSE = { | ||
| analyticsUnavailable: false, | ||
| allTags: ['some-tag'], | ||
| recentQueries: MOCK_RECENT_QUERIES, | ||
| topQueries: MOCK_TOP_QUERIES, | ||
| topQueriesNoResults: MOCK_TOP_QUERIES, | ||
| topQueriesNoClicks: MOCK_TOP_QUERIES, | ||
| topQueriesWithClicks: MOCK_TOP_QUERIES, | ||
| totalClicks: 1000, | ||
| totalQueries: 5000, | ||
| totalQueriesNoResults: 500, | ||
| clicksPerDay: [0, 10, 50], | ||
| queriesPerDay: [10, 50, 100], | ||
| queriesNoResultsPerDay: [1, 2, 3], | ||
| }; | ||
| const MOCK_QUERY_RESPONSE = { | ||
| analyticsUnavailable: false, | ||
| allTags: ['some-tag'], | ||
| totalQueriesForQuery: 50, | ||
| queriesPerDayForQuery: [25, 0, 25], | ||
| topClicksForQuery: MOCK_TOP_CLICKS, | ||
| }; | ||
|
|
||
| const { mount } = new LogicMounter(AnalyticsLogic); | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| KibanaLogic.values.history.location.search = ''; | ||
| }); | ||
|
|
||
| it('has expected default values', () => { | ||
| mount(); | ||
| expect(AnalyticsLogic.values).toEqual(DEFAULT_VALUES); | ||
| }); | ||
|
|
||
| describe('actions', () => { | ||
| describe('onAnalyticsUnavailable', () => { | ||
| it('should set state', () => { | ||
|
Comment on lines
+104
to
+105
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @JasonStoltz - I implemented the reducer block suggestions you noted in #87561 (comment)! I like this a lot so far, feels much faster 🎉 |
||
| mount(); | ||
| AnalyticsLogic.actions.onAnalyticsUnavailable(); | ||
|
|
||
| expect(AnalyticsLogic.values).toEqual({ | ||
| ...DEFAULT_VALUES, | ||
| dataLoading: false, | ||
| analyticsUnavailable: true, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('onAnalyticsDataLoad', () => { | ||
| it('should set state', () => { | ||
| mount(); | ||
| AnalyticsLogic.actions.onAnalyticsDataLoad(MOCK_ANALYTICS_RESPONSE); | ||
|
|
||
| expect(AnalyticsLogic.values).toEqual({ | ||
| ...DEFAULT_VALUES, | ||
| dataLoading: false, | ||
| analyticsUnavailable: false, | ||
| // TODO: more state will get set here in future PRs | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('onQueryDataLoad', () => { | ||
| it('should set state', () => { | ||
| mount(); | ||
| AnalyticsLogic.actions.onQueryDataLoad(MOCK_QUERY_RESPONSE); | ||
|
|
||
| expect(AnalyticsLogic.values).toEqual({ | ||
| ...DEFAULT_VALUES, | ||
| dataLoading: false, | ||
| analyticsUnavailable: false, | ||
| // TODO: more state will get set here in future PRs | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('listeners', () => { | ||
| describe('loadAnalyticsData', () => { | ||
| it('should set state', () => { | ||
| mount({ dataLoading: false }); | ||
|
|
||
| AnalyticsLogic.actions.loadAnalyticsData(); | ||
|
|
||
| expect(AnalyticsLogic.values).toEqual({ | ||
| ...DEFAULT_VALUES, | ||
| dataLoading: true, | ||
| }); | ||
| }); | ||
|
|
||
| it('should make an API call and set state based on the response', async () => { | ||
| const promise = Promise.resolve(MOCK_ANALYTICS_RESPONSE); | ||
| (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); | ||
| mount(); | ||
| jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsDataLoad'); | ||
|
|
||
| AnalyticsLogic.actions.loadAnalyticsData(); | ||
| await promise; | ||
|
|
||
| expect(HttpLogic.values.http.get).toHaveBeenCalledWith( | ||
| '/api/app_search/engines/test-engine/analytics/queries', | ||
| { | ||
| query: { size: 20 }, | ||
| } | ||
| ); | ||
| expect(AnalyticsLogic.actions.onAnalyticsDataLoad).toHaveBeenCalledWith( | ||
| MOCK_ANALYTICS_RESPONSE | ||
| ); | ||
| }); | ||
|
|
||
| it('parses and passes the current search query string', async () => { | ||
| (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce({}); | ||
| KibanaLogic.values.history.location.search = | ||
| '?start=1970-01-01&end=1970-01-02&&tag=some_tag'; | ||
| mount(); | ||
|
|
||
| AnalyticsLogic.actions.loadAnalyticsData(); | ||
|
|
||
| expect(HttpLogic.values.http.get).toHaveBeenCalledWith( | ||
| '/api/app_search/engines/test-engine/analytics/queries', | ||
| { | ||
| query: { | ||
| start: '1970-01-01', | ||
| end: '1970-01-02', | ||
| tag: 'some_tag', | ||
| size: 20, | ||
| }, | ||
| } | ||
| ); | ||
| }); | ||
|
|
||
| it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { | ||
| const promise = Promise.resolve({ analyticsUnavailable: true }); | ||
| (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); | ||
| mount(); | ||
| jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); | ||
|
|
||
| AnalyticsLogic.actions.loadAnalyticsData(); | ||
| await promise; | ||
|
|
||
| expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('handles errors', async () => { | ||
| const promise = Promise.reject('error'); | ||
| (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); | ||
| mount(); | ||
| jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); | ||
|
|
||
| try { | ||
| AnalyticsLogic.actions.loadAnalyticsData(); | ||
| await promise; | ||
| } catch { | ||
| // Do nothing | ||
| } | ||
|
Comment on lines
+218
to
+223
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we expect
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes. Rejected promises as well as 400/500 responses from the server (in production) will trigger the
You can try this yourself if you pull the branch down, removing this try catch will cause the test to throw an error. I believe this actually might be because
Unfortunately it's not quite that simple with Jest and async expects that are actually throws, the syntax is fairly bizarre and far more complex than this. I've struggled with this a lot in the past and am confident try/catch within the test is by far the easiest way to catch expected errors. I definitely agree we could clarify the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's complicated, IIRC. You can't catch an exception thrown by a listener, like I think it's cause you're not invoking the listener directly. They're on separate call stacks or something. It's like an evented system. Calling Does that much make sense? So this code is actually misleading. You could actually move So the point so far is that this test isn't actually testing that loadAnalyticsData throws correctly. This test is testing that the So here's the next challenge. We need to tell You can't do something like this: (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(Promise.reject());
await AnalyticsLogic.actions.loadAnalyticsData();Because const promise = Promise.reject('error');
(HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise);
await promise;When you await a rejected promise, it will throw, so you gotta wrap it up: const promise = Promise.reject('error');
(HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise);
try {
await promise;
} catch {
// Do nothing
}You may be tempted to run some assertions in the catch at this point.. const promise = Promise.reject('error');
(HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise);
try {
await promise;
} catch {
expect(flashAPIErrors).toHaveBeenCalledWith('error');
expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled();
}But I think you should generally avoid that ... because if for some reason your code ever stops throwing, then your code won't run the expectations, but the test will still pass, so you end up with a false positive. const promise = Promise.reject('error');
(HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise);
try {
await promise;
} catch {
// Do nothing
}
expect(flashAPIErrors).toHaveBeenCalledWith('error');
expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled();So that's generally how we arrived here. This could probably be improved. Any ideas?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you can get access to invoke a listener directly, that could simplify things.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow up PR: #88422 |
||
|
|
||
| expect(flashAPIErrors).toHaveBeenCalledWith('error'); | ||
| expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('loadQueryData', () => { | ||
| it('should set state', () => { | ||
| mount({ dataLoading: false }); | ||
|
|
||
| AnalyticsLogic.actions.loadQueryData('some-query'); | ||
|
|
||
| expect(AnalyticsLogic.values).toEqual({ | ||
| ...DEFAULT_VALUES, | ||
| dataLoading: true, | ||
| }); | ||
| }); | ||
|
|
||
| it('should make an API call and set state based on the response', async () => { | ||
| const promise = Promise.resolve(MOCK_QUERY_RESPONSE); | ||
| (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); | ||
| mount(); | ||
| jest.spyOn(AnalyticsLogic.actions, 'onQueryDataLoad'); | ||
|
|
||
| AnalyticsLogic.actions.loadQueryData('some-query'); | ||
| await promise; | ||
|
|
||
| expect(HttpLogic.values.http.get).toHaveBeenCalledWith( | ||
| '/api/app_search/engines/test-engine/analytics/queries/some-query', | ||
| expect.any(Object) // empty query obj | ||
| ); | ||
| expect(AnalyticsLogic.actions.onQueryDataLoad).toHaveBeenCalledWith(MOCK_QUERY_RESPONSE); | ||
| }); | ||
|
|
||
| it('parses and passes the current search query string', async () => { | ||
| (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce({}); | ||
| KibanaLogic.values.history.location.search = | ||
| '?start=1970-12-30&end=1970-12-31&&tag=another_tag'; | ||
| mount(); | ||
|
|
||
| AnalyticsLogic.actions.loadQueryData('some-query'); | ||
|
|
||
| expect(HttpLogic.values.http.get).toHaveBeenCalledWith( | ||
| '/api/app_search/engines/test-engine/analytics/queries/some-query', | ||
| { | ||
| query: { | ||
| start: '1970-12-30', | ||
| end: '1970-12-31', | ||
| tag: 'another_tag', | ||
| }, | ||
| } | ||
| ); | ||
| }); | ||
|
|
||
| it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { | ||
| const promise = Promise.resolve({ analyticsUnavailable: true }); | ||
| (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); | ||
| mount(); | ||
| jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); | ||
|
|
||
| AnalyticsLogic.actions.loadQueryData('some-query'); | ||
| await promise; | ||
|
|
||
| expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('handles errors', async () => { | ||
| const promise = Promise.reject('error'); | ||
| (HttpLogic.values.http.get as jest.Mock).mockReturnValueOnce(promise); | ||
| mount(); | ||
| jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); | ||
|
|
||
| try { | ||
| AnalyticsLogic.actions.loadQueryData('some-query'); | ||
| await promise; | ||
| } catch { | ||
| // Do nothing | ||
| } | ||
|
Comment on lines
+296
to
+301
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to above I'm not 100% you should be try/catch-ing here |
||
|
|
||
| expect(flashAPIErrors).toHaveBeenCalledWith('error'); | ||
| expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've found value in typing these sorts of default values for tests, it can catch stuff when the properties of these objects change
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not 100% convinced of this - our Kea logic should be type-checking for us and alerting if we have type issues. Not sure if
ent-searchhas been updated to usemakeLogicType, but in Kibana I definitely know it type checks my test files.