[SecuritySolution] Add timeline middleware tests (#178009)

## Summary

In the previous work for
https://github.com/elastic/kibana/issues/175427, we replaced
redux-observable with plain redux middlewares. The code that was based
on redux-observable wasn't tested, so as part of the refactoring we're
now adding tests to all timeline middlewares in this PR.


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Jan Monschke 2024-03-06 14:55:26 +01:00 committed by GitHub
parent bb33687d37
commit 8f39000270
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 679 additions and 5 deletions

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createMockStore } from '../../../common/mock';
import { selectTimelineById } from '../selectors';
import { TimelineId } from '../../../../common/types/timeline';
import {
setChanged,
updateKqlMode,
showTimeline,
applyKqlFilterQuery,
addProvider,
dataProviderEdited,
removeColumn,
removeProvider,
updateColumns,
updateEqlOptions,
updateDataProviderEnabled,
updateDataProviderExcluded,
updateDataProviderType,
updateProviders,
updateRange,
updateSort,
upsertColumn,
updateDataView,
updateTitleAndDescription,
setExcludedRowRendererIds,
setFilters,
setSavedQueryId,
updateSavedSearch,
} from '../actions';
import { timelineChangedTypes } from './timeline_changed';
jest.mock('../actions', () => {
const actual = jest.requireActual('../actions');
return {
...actual,
setChanged: jest.fn().mockImplementation((...args) => actual.setChanged(...args)),
};
});
/**
* This is a copy of the timeline changed types from the actual middleware.
* The purpose of this copy is to enforce changes to the original to fail.
* These changes will need to be applied to the copy to pass the tests.
* That way, we are preventing accidental changes to the original.
*/
const timelineChangedTypesCopy = [
applyKqlFilterQuery.type,
addProvider.type,
dataProviderEdited.type,
removeProvider.type,
setExcludedRowRendererIds.type,
setFilters.type,
setSavedQueryId.type,
updateDataProviderEnabled.type,
updateDataProviderExcluded.type,
updateDataProviderType.type,
updateEqlOptions.type,
updateKqlMode.type,
updateProviders.type,
updateTitleAndDescription.type,
updateDataView.type,
removeColumn.type,
updateColumns.type,
updateSort.type,
updateRange.type,
upsertColumn.type,
updateSavedSearch.type,
];
const setChangedMock = setChanged as unknown as jest.Mock;
describe('Timeline changed middleware', () => {
let store = createMockStore();
beforeEach(() => {
store = createMockStore();
setChangedMock.mockClear();
});
it('should mark a timeline as changed for some actions', () => {
expect(selectTimelineById(store.getState(), TimelineId.test).kqlMode).toEqual('filter');
store.dispatch(updateKqlMode({ id: TimelineId.test, kqlMode: 'search' }));
expect(setChangedMock).toHaveBeenCalledWith({ id: TimelineId.test, changed: true });
expect(selectTimelineById(store.getState(), TimelineId.test).kqlMode).toEqual('search');
});
it('should check that all correct actions are used to check for changes', () => {
timelineChangedTypesCopy.forEach((changedType) => {
expect(timelineChangedTypes.has(changedType)).toBeTruthy();
});
});
it('should not mark a timeline as changed for some actions', () => {
store.dispatch(showTimeline({ id: TimelineId.test, show: true }));
expect(setChangedMock).not.toHaveBeenCalled();
});
});

View file

@ -36,7 +36,7 @@ import {
/**
* All action types that will mark a timeline as changed
*/
const timelineChangedTypes = new Set([
export const timelineChangedTypes = new Set([
applyKqlFilterQuery.type,
addProvider.type,
dataProviderEdited.type,

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createMockStore, kibanaMock } from '../../../common/mock';
import { selectTimelineById } from '../selectors';
import { persistFavorite } from '../../containers/api';
import { TimelineId } from '../../../../common/types/timeline';
import { refreshTimelines } from './helpers';
import {
startTimelineSaving,
endTimelineSaving,
updateIsFavorite,
showCallOutUnauthorizedMsg,
updateTimeline,
} from '../actions';
jest.mock('../actions', () => {
const actual = jest.requireActual('../actions');
const endTLSaving = jest.fn((...args) => actual.endTimelineSaving(...args));
(endTLSaving as unknown as { match: Function }).match = () => false;
return {
...actual,
showCallOutUnauthorizedMsg: jest
.fn()
.mockImplementation((...args) => actual.showCallOutUnauthorizedMsg(...args)),
startTimelineSaving: jest
.fn()
.mockImplementation((...args) => actual.startTimelineSaving(...args)),
endTimelineSaving: endTLSaving,
};
});
jest.mock('../../containers/api');
jest.mock('./helpers');
const startTimelineSavingMock = startTimelineSaving as unknown as jest.Mock;
const endTimelineSavingMock = endTimelineSaving as unknown as jest.Mock;
const showCallOutUnauthorizedMsgMock = showCallOutUnauthorizedMsg as unknown as jest.Mock;
describe('Timeline favorite middleware', () => {
let store = createMockStore(undefined, undefined, kibanaMock);
const newVersion = 'new_version';
const newSavedObjectId = 'new_so_id';
beforeEach(() => {
store = createMockStore(undefined, undefined, kibanaMock);
jest.clearAllMocks();
});
it('should persist a timeline favorite when a favorite action is dispatched', async () => {
(persistFavorite as jest.Mock).mockResolvedValue({
data: {
persistFavorite: {
code: 200,
favorite: [{}],
savedObjectId: newSavedObjectId,
version: newVersion,
},
},
});
expect(selectTimelineById(store.getState(), TimelineId.test).isFavorite).toEqual(false);
await store.dispatch(updateIsFavorite({ id: TimelineId.test, isFavorite: true }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual(
expect.objectContaining({
isFavorite: true,
savedObjectId: newSavedObjectId,
version: newVersion,
})
);
});
it('should persist a timeline un-favorite when a favorite action is dispatched for a favorited timeline', async () => {
store.dispatch(
updateTimeline({
id: TimelineId.test,
timeline: {
...selectTimelineById(store.getState(), TimelineId.test),
isFavorite: true,
},
})
);
(persistFavorite as jest.Mock).mockResolvedValue({
data: {
persistFavorite: {
code: 200,
favorite: [],
savedObjectId: newSavedObjectId,
version: newVersion,
},
},
});
expect(selectTimelineById(store.getState(), TimelineId.test).isFavorite).toEqual(true);
await store.dispatch(updateIsFavorite({ id: TimelineId.test, isFavorite: false }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual(
expect.objectContaining({
isFavorite: false,
savedObjectId: newSavedObjectId,
version: newVersion,
})
);
});
it('should show an error message when the call is unauthorized', async () => {
(persistFavorite as jest.Mock).mockResolvedValue({
data: {
persistFavorite: {
code: 403,
},
},
});
await store.dispatch(updateIsFavorite({ id: TimelineId.test, isFavorite: true }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(showCallOutUnauthorizedMsgMock).toHaveBeenCalled();
});
it('should show a generic error when the persistence throws', async () => {
const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger');
(persistFavorite as jest.Mock).mockImplementation(() => {
throw new Error();
});
await store.dispatch(updateIsFavorite({ id: TimelineId.test, isFavorite: true }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(addDangerMock).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,136 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createMockStore, kibanaMock } from '../../../common/mock';
import { selectTimelineById } from '../selectors';
import { TimelineId } from '../../../../common/types/timeline';
import { persistNote } from '../../containers/notes/api';
import { refreshTimelines } from './helpers';
import {
startTimelineSaving,
endTimelineSaving,
showCallOutUnauthorizedMsg,
addNote,
addNoteToEvent,
} from '../actions';
import { updateNote } from '../../../common/store/app/actions';
import { createNote } from '../../components/notes/helpers';
jest.mock('../actions', () => {
const actual = jest.requireActual('../actions');
const endTLSaving = jest.fn((...args) => actual.endTimelineSaving(...args));
(endTLSaving as unknown as { match: Function }).match = () => false;
return {
...actual,
showCallOutUnauthorizedMsg: jest
.fn()
.mockImplementation((...args) => actual.showCallOutUnauthorizedMsg(...args)),
startTimelineSaving: jest
.fn()
.mockImplementation((...args) => actual.startTimelineSaving(...args)),
endTimelineSaving: endTLSaving,
};
});
jest.mock('../../containers/notes/api');
jest.mock('./helpers');
const startTimelineSavingMock = startTimelineSaving as unknown as jest.Mock;
const endTimelineSavingMock = endTimelineSaving as unknown as jest.Mock;
const showCallOutUnauthorizedMsgMock = showCallOutUnauthorizedMsg as unknown as jest.Mock;
describe('Timeline note middleware', () => {
let store = createMockStore(undefined, undefined, kibanaMock);
const testNote = createNote({ newNote: 'test', user: 'elastic' });
const testEventId = 'test';
beforeEach(() => {
store = createMockStore(undefined, undefined, kibanaMock);
jest.clearAllMocks();
});
it('should persist a timeline note', async () => {
(persistNote as jest.Mock).mockResolvedValue({
data: {
persistNote: {
code: 200,
message: 'success',
note: {
noteId: testNote.id,
},
},
},
});
expect(selectTimelineById(store.getState(), TimelineId.test).noteIds).toEqual([]);
await store.dispatch(updateNote({ note: testNote }));
await store.dispatch(addNote({ id: TimelineId.test, noteId: testNote.id }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(selectTimelineById(store.getState(), TimelineId.test).noteIds).toContain(testNote.id);
});
it('should persist a note on an event of a timeline', async () => {
(persistNote as jest.Mock).mockResolvedValue({
data: {
persistNote: {
code: 200,
message: 'success',
note: {
noteId: testNote.id,
},
},
},
});
expect(selectTimelineById(store.getState(), TimelineId.test).eventIdToNoteIds).toEqual({});
await store.dispatch(updateNote({ note: testNote }));
await store.dispatch(
addNoteToEvent({ eventId: testEventId, id: TimelineId.test, noteId: testNote.id })
);
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(selectTimelineById(store.getState(), TimelineId.test).eventIdToNoteIds).toEqual(
expect.objectContaining({
[testEventId]: [testNote.id],
})
);
});
it('should show an error message when the call is unauthorized', async () => {
(persistNote as jest.Mock).mockResolvedValue({
data: {
persistNote: {
code: 403,
},
},
});
await store.dispatch(updateNote({ note: testNote }));
await store.dispatch(addNote({ id: TimelineId.test, noteId: testNote.id }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(showCallOutUnauthorizedMsgMock).toHaveBeenCalled();
});
it('should show a generic error when the persistence throws', async () => {
const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger');
(persistNote as jest.Mock).mockImplementation(() => {
throw new Error();
});
await store.dispatch(updateNote({ note: testNote }));
await store.dispatch(addNote({ id: TimelineId.test, noteId: testNote.id }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(addDangerMock).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createMockStore, kibanaMock } from '../../../common/mock';
import { selectTimelineById } from '../selectors';
import { TimelineId } from '../../../../common/types/timeline';
import { persistPinnedEvent } from '../../containers/pinned_event/api';
import { refreshTimelines } from './helpers';
import {
startTimelineSaving,
endTimelineSaving,
pinEvent,
unPinEvent,
showCallOutUnauthorizedMsg,
updateTimeline,
} from '../actions';
jest.mock('../actions', () => {
const actual = jest.requireActual('../actions');
const endTLSaving = jest.fn((...args) => actual.endTimelineSaving(...args));
(endTLSaving as unknown as { match: Function }).match = () => false;
return {
...actual,
showCallOutUnauthorizedMsg: jest
.fn()
.mockImplementation((...args) => actual.showCallOutUnauthorizedMsg(...args)),
startTimelineSaving: jest
.fn()
.mockImplementation((...args) => actual.startTimelineSaving(...args)),
endTimelineSaving: endTLSaving,
};
});
jest.mock('../../containers/pinned_event/api');
jest.mock('./helpers');
const startTimelineSavingMock = startTimelineSaving as unknown as jest.Mock;
const endTimelineSavingMock = endTimelineSaving as unknown as jest.Mock;
const showCallOutUnauthorizedMsgMock = showCallOutUnauthorizedMsg as unknown as jest.Mock;
describe('Timeline pinned event middleware', () => {
let store = createMockStore(undefined, undefined, kibanaMock);
const testEventId = 'test';
beforeEach(() => {
store = createMockStore(undefined, undefined, kibanaMock);
jest.clearAllMocks();
});
it('should persist a timeline pin event action', async () => {
(persistPinnedEvent as jest.Mock).mockResolvedValue({
data: {
persistPinnedEventOnTimeline: {
code: 200,
},
},
});
expect(selectTimelineById(store.getState(), TimelineId.test).pinnedEventIds).toEqual({});
await store.dispatch(pinEvent({ id: TimelineId.test, eventId: testEventId }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(selectTimelineById(store.getState(), TimelineId.test).pinnedEventIds).toEqual({
[testEventId]: true,
});
});
it('should persist a timeline un-pin event', async () => {
store.dispatch(
updateTimeline({
id: TimelineId.test,
timeline: {
...selectTimelineById(store.getState(), TimelineId.test),
pinnedEventIds: {
[testEventId]: true,
},
},
})
);
(persistPinnedEvent as jest.Mock).mockResolvedValue({
data: {},
});
expect(selectTimelineById(store.getState(), TimelineId.test).pinnedEventIds).toEqual({
[testEventId]: true,
});
await store.dispatch(unPinEvent({ id: TimelineId.test, eventId: testEventId }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(selectTimelineById(store.getState(), TimelineId.test).pinnedEventIds).toEqual({});
});
it('should show an error message when the call is unauthorized', async () => {
(persistPinnedEvent as jest.Mock).mockResolvedValue({
data: {
persistPinnedEventOnTimeline: {
code: 403,
},
},
});
await store.dispatch(unPinEvent({ id: TimelineId.test, eventId: testEventId }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(showCallOutUnauthorizedMsgMock).toHaveBeenCalled();
});
it('should show a generic error when the persistence throws', async () => {
const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger');
(persistPinnedEvent as jest.Mock).mockImplementation(() => {
throw new Error();
});
await store.dispatch(pinEvent({ id: TimelineId.test, eventId: testEventId }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(addDangerMock).toHaveBeenCalled();
});
});

View file

@ -8,12 +8,172 @@
import type { Filter } from '@kbn/es-query';
import { FilterStateStore } from '@kbn/es-query';
import { Direction } from '../../../../common/search_strategy';
import { TimelineTabs } from '../../../../common/types/timeline';
import { TimelineId, TimelineTabs } from '../../../../common/types/timeline';
import { TimelineType, TimelineStatus } from '../../../../common/api/timeline';
import { convertTimelineAsInput } from './timeline_save';
import type { TimelineModel } from '../model';
import { createMockStore, kibanaMock } from '../../../common/mock';
import { selectTimelineById } from '../selectors';
import { copyTimeline, persistTimeline } from '../../containers/api';
import { refreshTimelines } from './helpers';
import * as i18n from '../../pages/translations';
import {
startTimelineSaving,
endTimelineSaving,
showCallOutUnauthorizedMsg,
saveTimeline,
setChanged,
} from '../actions';
jest.mock('../actions', () => {
const actual = jest.requireActual('../actions');
const endTLSaving = jest.fn((...args) => actual.endTimelineSaving(...args));
(endTLSaving as unknown as { match: Function }).match = () => false;
return {
...actual,
showCallOutUnauthorizedMsg: jest
.fn()
.mockImplementation((...args) => actual.showCallOutUnauthorizedMsg(...args)),
startTimelineSaving: jest
.fn()
.mockImplementation((...args) => actual.startTimelineSaving(...args)),
endTimelineSaving: endTLSaving,
};
});
jest.mock('../../containers/api');
jest.mock('./helpers');
const startTimelineSavingMock = startTimelineSaving as unknown as jest.Mock;
const endTimelineSavingMock = endTimelineSaving as unknown as jest.Mock;
const showCallOutUnauthorizedMsgMock = showCallOutUnauthorizedMsg as unknown as jest.Mock;
describe('Timeline save middleware', () => {
let store = createMockStore(undefined, undefined, kibanaMock);
beforeEach(() => {
store = createMockStore(undefined, undefined, kibanaMock);
jest.clearAllMocks();
});
it('should persist a timeline', async () => {
(persistTimeline as jest.Mock).mockResolvedValue({
data: {
persistTimeline: {
code: 200,
message: 'success',
timeline: {
savedObjectId: 'soid',
version: 'newVersion',
},
},
},
});
await store.dispatch(setChanged({ id: TimelineId.test, changed: true }));
expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual(
expect.objectContaining({
version: null,
changed: true,
})
);
await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: false }));
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(persistTimeline as unknown as jest.Mock).toHaveBeenCalled();
expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual(
expect.objectContaining({
version: 'newVersion',
changed: false,
})
);
});
it('should copy a timeline', async () => {
(copyTimeline as jest.Mock).mockResolvedValue({
data: {
persistTimeline: {
code: 200,
message: 'success',
timeline: {
savedObjectId: 'soid',
version: 'newVersion',
},
},
},
});
await store.dispatch(setChanged({ id: TimelineId.test, changed: true }));
expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual(
expect.objectContaining({
version: null,
changed: true,
})
);
await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: true }));
expect(copyTimeline as unknown as jest.Mock).toHaveBeenCalled();
expect(startTimelineSavingMock).toHaveBeenCalled();
expect(refreshTimelines as unknown as jest.Mock).toHaveBeenCalled();
expect(endTimelineSavingMock).toHaveBeenCalled();
expect(selectTimelineById(store.getState(), TimelineId.test)).toEqual(
expect.objectContaining({
version: 'newVersion',
changed: false,
})
);
});
it('should show an error message in case of a conflict', async () => {
const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger');
(copyTimeline as jest.Mock).mockResolvedValue({
status_code: 409,
message: 'test conflict',
});
await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: true }));
expect(refreshTimelines as unknown as jest.Mock).not.toHaveBeenCalled();
expect(addDangerMock).toHaveBeenCalledWith({
title: i18n.TIMELINE_VERSION_CONFLICT_TITLE,
text: i18n.TIMELINE_VERSION_CONFLICT_DESCRIPTION,
});
});
it('should show the provided message in case of an error response', async () => {
const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger');
(persistTimeline as jest.Mock).mockResolvedValue({
status_code: 404,
message: 'test error message',
});
await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: false }));
expect(refreshTimelines as unknown as jest.Mock).not.toHaveBeenCalled();
expect(addDangerMock).toHaveBeenCalledWith({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: 'test error message',
});
});
it('should show a generic error in case of an empty response', async () => {
const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger');
(persistTimeline as jest.Mock).mockResolvedValue(null);
await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: false }));
expect(refreshTimelines as unknown as jest.Mock).not.toHaveBeenCalled();
expect(addDangerMock).toHaveBeenCalledWith({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
});
it('should show an error message when the call is unauthorized', async () => {
(persistTimeline as jest.Mock).mockResolvedValue({ data: { persistTimeline: { code: 403 } } });
await store.dispatch(saveTimeline({ id: TimelineId.test, saveAsNew: false }));
expect(refreshTimelines as unknown as jest.Mock).not.toHaveBeenCalled();
expect(showCallOutUnauthorizedMsgMock).toHaveBeenCalled();
});
describe('Timeline Save Middleware', () => {
describe('#convertTimelineAsInput ', () => {
test('should return a TimelineInput instead of TimelineModel ', () => {
const columns: TimelineModel['columns'] = [

View file

@ -99,7 +99,7 @@ export const saveTimelineMiddleware: (kibana: CoreStart) => Middleware<{}, State
return;
}
const response = result.data.persistTimeline;
const response = result?.data?.persistTimeline;
if (response == null) {
kibana.notifications.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
@ -273,7 +273,7 @@ const convertToString = (obj: unknown) => {
type PossibleResponse = TimelineResponse | TimelineErrorResponse;
function isTimelineErrorResponse(response: PossibleResponse): response is TimelineErrorResponse {
return 'status_code' in response || 'statusCode' in response;
return response && ('status_code' in response || 'statusCode' in response);
}
function getErrorFromResponse(response: TimelineErrorResponse) {