[Cases] Create internal endpoint to get user action stats (#149863)

Fixes #149390

## Summary

This PR creates an internal API to get the count of the different user
actions associated with the current case.

This will be used to help filter and paginate the case activity.

<img width="1025" alt="aux"
src="https://user-images.githubusercontent.com/1533137/215549427-373f1626-3f7a-417d-ad95-ddb47b259617.png">

**Endpoint:**
`GET /internal/cases/<case_id>/user_actions/_stats`

Example Response:

```
{
    total: 3
    total_comments: 2
    total_other_actions: 1
}
```

### Checklist

Delete any items that are not applicable to this PR.

- [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

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Antonio 2023-02-01 18:50:53 +01:00 committed by GitHub
parent 4795910ef3
commit 7ae33a75ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 623 additions and 1 deletions

View file

@ -8,6 +8,7 @@
import * as rt from 'io-ts';
import type { ActionsRt, ActionTypeValues } from './common';
import {
CaseUserActionInjectedIdsRt,
CaseUserActionInjectedDeprecatedIdsRt,
@ -25,6 +26,7 @@ import { StatusUserActionRt } from './status';
import { DeleteCaseUserActionRt } from './delete_case';
import { SeverityUserActionRt } from './severity';
import { AssigneesUserActionRt } from './assignees';
import { CaseUserActionStatsRt } from './stats';
const CommonUserActionsRt = rt.union([
DescriptionUserActionRt,
@ -83,11 +85,13 @@ const CaseUserActionResponseRt = rt.intersection([
const CaseUserActionAttributesRt = CaseUserActionBasicRt;
export const CaseUserActionsResponseRt = rt.array(CaseUserActionResponseRt);
export const CaseUserActionsDeprecatedResponseRt = rt.array(CaseUserActionDeprecatedResponseRt);
export const CaseUserActionStatsResponseRt = CaseUserActionStatsRt;
export type CaseUserActionAttributes = rt.TypeOf<typeof CaseUserActionAttributesRt>;
export type CaseUserActionAttributesWithoutConnectorId = rt.TypeOf<
typeof CaseUserActionBasicWithoutConnectorIdRt
>;
export type CaseUserActionStatsResponse = rt.TypeOf<typeof CaseUserActionStatsRt>;
export type CaseUserActionsResponse = rt.TypeOf<typeof CaseUserActionsResponseRt>;
export type CaseUserActionResponse = rt.TypeOf<typeof CaseUserActionResponseRt>;
export type CaseUserActionsDeprecatedResponse = rt.TypeOf<

View file

@ -0,0 +1,16 @@
/*
* 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 * as rt from 'io-ts';
export const CaseUserActionStatsRt = rt.type({
total: rt.number,
total_comments: rt.number,
total_other_actions: rt.number,
});
export type CaseUserActionStats = rt.TypeOf<typeof CaseUserActionStatsRt>;

View file

@ -16,6 +16,7 @@ import {
CASE_ALERTS_URL,
CASE_COMMENT_DELETE_URL,
CASE_FIND_USER_ACTIONS_URL,
INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL,
INTERNAL_BULK_GET_ATTACHMENTS_URL,
INTERNAL_CONNECTORS_URL,
} from '../constants';
@ -44,6 +45,10 @@ export const getCaseUserActionUrl = (id: string): string => {
return CASE_USER_ACTIONS_URL.replace('{case_id}', id);
};
export const getCaseUserActionStatsUrl = (id: string): string => {
return INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL.replace('{case_id}', id);
};
export const getCaseFindUserActionsUrl = (id: string): string => {
return CASE_FIND_USER_ACTIONS_URL.replace('{case_id}', id);
};

View file

@ -94,6 +94,8 @@ export const INTERNAL_SUGGEST_USER_PROFILES_URL =
`${CASES_INTERNAL_URL}/_suggest_user_profiles` as const;
export const INTERNAL_CONNECTORS_URL = `${CASES_INTERNAL_URL}/{case_id}/_connectors` as const;
export const INTERNAL_BULK_GET_CASES_URL = `${CASES_INTERNAL_URL}/_bulk_get` as const;
export const INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL =
`${CASES_INTERNAL_URL}/{case_id}/user_actions/_stats` as const;
/**
* Action routes

View file

@ -2184,6 +2184,90 @@ Object {
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActionStats" with an error and entity 1`] = `
Object {
"error": Object {
"code": "Error",
"message": "an error",
},
"event": Object {
"action": "case_user_action_get_stats",
"category": Array [
"database",
],
"outcome": "failure",
"type": Array [
"access",
],
},
"kibana": Object {
"saved_object": Object {
"id": "1",
"type": "cases-user-actions",
},
},
"message": "Failed attempt to access cases-user-actions [id=1] as owner \\"awesome\\"",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActionStats" with an error but no entity 1`] = `
Object {
"error": Object {
"code": "Error",
"message": "an error",
},
"event": Object {
"action": "case_user_action_get_stats",
"category": Array [
"database",
],
"outcome": "failure",
"type": Array [
"access",
],
},
"message": "Failed attempt to access a user actions as any owners",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActionStats" without an error but with an entity 1`] = `
Object {
"event": Object {
"action": "case_user_action_get_stats",
"category": Array [
"database",
],
"outcome": "success",
"type": Array [
"access",
],
},
"kibana": Object {
"saved_object": Object {
"id": "5",
"type": "cases-user-actions",
},
},
"message": "User has accessed cases-user-actions [id=5] as owner \\"super\\"",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActionStats" without an error or entity 1`] = `
Object {
"event": Object {
"action": "case_user_action_get_stats",
"category": Array [
"database",
],
"outcome": "success",
"type": Array [
"access",
],
},
"message": "User has accessed a user actions as any owners",
}
`;
exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActions" with an error and entity 1`] = `
Object {
"error": Object {

View file

@ -359,4 +359,12 @@ export const Operations: Record<ReadOperations | WriteOperations, OperationDetai
docType: 'user actions',
savedObjectType: CASE_USER_ACTION_SAVED_OBJECT,
},
[ReadOperations.GetUserActionStats]: {
ecsType: EVENT_TYPES.access,
name: ACCESS_USER_ACTION_OPERATION,
action: 'case_user_action_get_stats',
verbs: accessVerbs,
docType: 'user actions',
savedObjectType: CASE_USER_ACTION_SAVED_OBJECT,
},
};

View file

@ -47,6 +47,7 @@ export enum ReadOperations {
GetCaseMetrics = 'getCaseMetrics',
GetCasesMetrics = 'getCasesMetrics',
GetUserActionMetrics = 'getUserActionMetrics',
GetUserActionStats = 'getUserActionStats',
}
/**

View file

@ -91,9 +91,10 @@ type UserActionsSubClientMock = jest.Mocked<UserActionsSubClient>;
const createUserActionsSubClientMock = (): UserActionsSubClientMock => {
return {
find: jest.fn(),
getAll: jest.fn(),
getConnectors: jest.fn(),
find: jest.fn(),
stats: jest.fn(),
};
};

View file

@ -7,12 +7,14 @@
import type {
GetCaseConnectorsResponse,
CaseUserActionStatsResponse,
UserActionFindResponse,
CaseUserActionsDeprecatedResponse,
} from '../../../common/api';
import type { CasesClientArgs } from '../types';
import { get } from './get';
import { getConnectors } from './connectors';
import { getStats } from './stats';
import type { GetConnectorsRequest, UserActionFind, UserActionGet } from './types';
import { find } from './find';
import type { CasesClient } from '../client';
@ -30,6 +32,11 @@ export interface UserActionsSubClient {
* Retrieves all the connectors used within a given case
*/
getConnectors(params: GetConnectorsRequest): Promise<GetCaseConnectorsResponse>;
/**
* Retrieves the total of comments and user actions in a given case
*/
stats(params: UserActionGet): Promise<CaseUserActionStatsResponse>;
}
/**
@ -43,6 +50,7 @@ export const createUserActionsSubClient = (
find: (params) => find(params, casesClient, clientArgs),
getAll: (params) => get(params, clientArgs),
getConnectors: (params) => getConnectors(params, clientArgs),
stats: (params) => getStats(params, casesClient, clientArgs),
};
return Object.freeze(attachmentSubClient);

View file

@ -0,0 +1,39 @@
/*
* 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 type { CaseUserActionStatsResponse } from '../../../common/api';
import { CaseUserActionStatsResponseRt } from '../../../common/api';
import { createCaseError } from '../../common/error';
import type { CasesClientArgs } from '..';
import type { UserActionGet } from './types';
import type { CasesClient } from '../client';
export const getStats = async (
{ caseId }: UserActionGet,
casesClient: CasesClient,
clientArgs: CasesClientArgs
): Promise<CaseUserActionStatsResponse> => {
const {
services: { userActionService },
logger,
} = clientArgs;
try {
await casesClient.cases.resolve({ id: caseId, includeComments: false });
const totals = await userActionService.getCaseUserActionStats({
caseId,
});
return CaseUserActionStatsResponseRt.encode(totals);
} catch (error) {
throw createCaseError({
message: `Failed to retrieve user action stats for case id: ${caseId}: ${error}`,
error,
logger,
});
}
};

View file

@ -7,6 +7,7 @@
import type { UserProfileService } from '../../services';
import { getConnectorsRoute } from './internal/get_connectors';
import { getCaseUserActionStatsRoute } from './internal/get_case_user_actions_stats';
import { bulkCreateAttachmentsRoute } from './internal/bulk_create_attachments';
import { bulkGetCasesRoute } from './internal/bulk_get_cases';
import { suggestUserProfilesRoute } from './internal/suggest_user_profiles';
@ -19,5 +20,6 @@ export const getInternalRoutes = (userProfileService: UserProfileService) =>
suggestUserProfilesRoute(userProfileService),
getConnectorsRoute,
bulkGetCasesRoute,
getCaseUserActionStatsRoute,
bulkGetAttachmentsRoute,
] as CaseRoute[];

View file

@ -0,0 +1,36 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL } from '../../../../common/constants';
import { createCaseError } from '../../../common/error';
import { createCasesRoute } from '../create_cases_route';
export const getCaseUserActionStatsRoute = createCasesRoute({
method: 'get',
path: INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL,
params: {
params: schema.object({
case_id: schema.string(),
}),
},
handler: async ({ context, request, response }) => {
try {
const casesContext = await context.cases;
const casesClient = await casesContext.getCasesClient();
const caseId = request.params.case_id;
return response.ok({
body: await casesClient.userActions.stats({ caseId }),
});
} catch (error) {
throw createCaseError({
message: `Failed to retrieve stats in route case id: ${request.params.case_id}: ${error}`,
error,
});
}
},
});

View file

@ -124,6 +124,7 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => {
getAll: jest.fn(),
getUniqueConnectors: jest.fn(),
getUserActionIdsForCases: jest.fn(),
getCaseUserActionStats: jest.fn(),
};
// the cast here is required because jest.Mocked tries to include private members and would throw an error

View file

@ -88,6 +88,16 @@ interface ConnectorFieldsBeforePushAggsResult {
};
}
interface UserActionsStatsAggsResult {
total: number;
totals: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
}
export class CaseUserActionService {
private readonly _creator: UserActionPersister;
private readonly _finder: UserActionFinder;
@ -661,4 +671,48 @@ export class CaseUserActionService {
},
};
}
public async getCaseUserActionStats({ caseId }: { caseId: string }) {
const response = await this.context.unsecuredSavedObjectsClient.find<
CaseUserActionAttributesWithoutConnectorId,
UserActionsStatsAggsResult
>({
type: CASE_USER_ACTION_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
page: 1,
perPage: 1,
sortField: defaultSortField,
aggs: CaseUserActionService.buildUserActionStatsAgg(),
});
const result = {
total: response.total,
total_comments: 0,
total_other_actions: 0,
};
response.aggregations?.totals.buckets.forEach(({ key, doc_count: docCount }) => {
if (key === 'user') {
result.total_comments = docCount;
}
});
result.total_other_actions = result.total - result.total_comments;
return result;
}
private static buildUserActionStatsAgg(): Record<
string,
estypes.AggregationsAggregationContainer
> {
return {
totals: {
terms: {
field: `${CASE_USER_ACTION_SAVED_OBJECT}.attributes.payload.comment.type`,
size: 100,
},
},
};
}
}

View file

@ -12,6 +12,8 @@ import {
getCaseUserActionUrl,
CaseUserActionDeprecatedResponse,
CaseUserActionsDeprecatedResponse,
getCaseUserActionStatsUrl,
CaseUserActionStatsResponse,
} from '@kbn/cases-plugin/common/api';
import type SuperTest from 'supertest';
import { User } from './authentication/types';
@ -68,3 +70,22 @@ export const findCaseUserActions = async ({
return userActions;
};
export const getCaseUserActionStats = async ({
supertest,
caseID,
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
caseID: string;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<CaseUserActionStatsResponse> => {
const { body: userActionStats } = await supertest
.get(`${getSpaceUrlPrefix(auth.space)}${getCaseUserActionStatsUrl(caseID)}`)
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return userActionStats;
};

View file

@ -31,6 +31,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./cases/tags/get_tags'));
loadTestFile(require.resolve('./user_actions/get_all_user_actions'));
loadTestFile(require.resolve('./user_actions/find_user_actions'));
loadTestFile(require.resolve('./user_actions/get_user_action_stats'));
loadTestFile(require.resolve('./configure/get_configure'));
loadTestFile(require.resolve('./configure/patch_configure'));
loadTestFile(require.resolve('./configure/post_configure'));

View file

@ -0,0 +1,206 @@
/*
* 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 expect from '@kbn/expect';
import {
CaseResponse,
CaseSeverity,
CaseStatuses,
ConnectorTypes,
} from '@kbn/cases-plugin/common/api';
import {
globalRead,
obsSec,
obsSecRead,
noKibanaPrivileges,
secOnly,
secOnlyRead,
superUser,
obsOnly,
obsOnlyRead,
} from '../../../../common/lib/authentication/users';
import { getCaseUserActionStats } from '../../../../common/lib/user_actions';
import {
getPostCaseRequest,
persistableStateAttachment,
postCaseReq,
postCommentActionsReq,
postCommentAlertReq,
postCommentUserReq,
postExternalReferenceESReq,
} from '../../../../common/lib/mock';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
bulkCreateAttachments,
createCase,
createComment,
deleteAllCaseItems,
updateCase,
superUserSpace1Auth,
} from '../../../../common/lib/utils';
const getCaseUpdateData = (id: string, version: string) => ({
status: CaseStatuses.open,
severity: CaseSeverity.MEDIUM,
title: 'new title',
description: 'new desc',
settings: {
syncAlerts: false,
},
tags: ['one', 'two'],
connector: {
id: 'my-id',
name: 'Jira',
type: ConnectorTypes.jira as const,
fields: null,
},
id,
version,
});
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
describe('get_user_action_stats', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('returns correct total for comments', async () => {
// 1 creation action
const theCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: theCase.id,
params: [
// Only this one should show up in total_comments
postCommentUserReq,
// The ones below count as total_other_actions
postExternalReferenceESReq,
persistableStateAttachment,
postCommentActionsReq,
postCommentAlertReq,
],
});
const userActionTotals = await getCaseUserActionStats({ supertest, caseID: theCase.id });
expect(userActionTotals.total).to.equal(6);
expect(userActionTotals.total_comments).to.equal(1);
expect(userActionTotals.total_other_actions).to.equal(5);
expect(userActionTotals.total).to.equal(
userActionTotals.total_comments + userActionTotals.total_other_actions
);
});
it('returns the correct stats when a case update occurs', async () => {
// 1 creation action
const theCase = await createCase(supertest, postCaseReq);
// this update should account for 7 "other actions"
await updateCase({
supertest,
params: {
cases: [getCaseUpdateData(theCase.id, theCase.version)],
},
});
await bulkCreateAttachments({
supertest,
caseId: theCase.id,
params: [
// only this one should show up in total_comments
postCommentUserReq,
// the ones below count as total_other_actions
postExternalReferenceESReq,
persistableStateAttachment,
postCommentActionsReq,
postCommentAlertReq,
],
});
const userActionTotals = await getCaseUserActionStats({ supertest, caseID: theCase.id });
expect(userActionTotals.total).to.equal(13);
expect(userActionTotals.total_comments).to.equal(1);
expect(userActionTotals.total_other_actions).to.equal(12);
expect(userActionTotals.total).to.equal(
userActionTotals.total_comments + userActionTotals.total_other_actions
);
});
describe('rbac', () => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
let theCase: CaseResponse;
beforeEach(async () => {
theCase = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, {
user: superUser,
space: 'space1',
});
await updateCase({
supertest: supertestWithoutAuth,
params: {
cases: [getCaseUpdateData(theCase.id, theCase.version)],
},
auth: superUserSpace1Auth,
});
await createComment({
supertest,
caseId: theCase.id,
params: postCommentUserReq,
auth: superUserSpace1Auth,
});
});
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('should get the user actions for a case when the user has the correct permissions', async () => {
for (const user of [globalRead, superUser, secOnly, secOnlyRead, obsSec, obsSecRead]) {
const userActionTotals = await getCaseUserActionStats({
supertest: supertestWithoutAuth,
caseID: theCase.id,
auth: { user, space: 'space1' },
});
expect(userActionTotals.total).to.equal(9);
expect(userActionTotals.total_comments).to.equal(1);
expect(userActionTotals.total_other_actions).to.equal(8);
expect(userActionTotals.total).to.equal(
userActionTotals.total_comments + userActionTotals.total_other_actions
);
}
});
for (const scenario of [
{ user: noKibanaPrivileges, space: 'space1' },
{ user: secOnly, space: 'space2' },
{ user: obsOnly, space: 'space1' },
{ user: obsOnlyRead, space: 'space1' },
]) {
it(`should 403 when requesting the user action stats of a case with user ${
scenario.user.username
} with role(s) ${scenario.user.roles.join()} and space ${scenario.space}`, async () => {
await getCaseUserActionStats({
supertest: supertestWithoutAuth,
caseID: theCase.id,
auth: { user: scenario.user, space: scenario.space },
expectedHttpCode: 403,
});
});
}
});
});
};

View file

@ -38,6 +38,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => {
loadTestFile(require.resolve('./user_profiles/get_current'));
// Internal routes
loadTestFile(require.resolve('./internal/get_user_action_stats'));
loadTestFile(require.resolve('./internal/suggest_user_profiles'));
loadTestFile(require.resolve('./internal/get_connectors'));

View file

@ -0,0 +1,132 @@
/*
* 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 http from 'http';
import expect from '@kbn/expect';
import { ConnectorTypes } from '@kbn/cases-plugin/common/api';
import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { createCase, deleteAllCaseItems, pushCase, updateCase } from '../../../../common/lib/utils';
import { postCaseReq } from '../../../../common/lib/mock';
import {
createCaseWithConnector,
createConnector,
getJiraConnector,
getServiceNowSimulationServer,
} from '../../../../common/lib/connectors';
import { getCaseUserActionStats } from '../../../../common/lib/user_actions';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
const actionsRemover = new ActionsRemover(supertest);
describe('get_case_user_action_stats', () => {
let serviceNowSimulatorURL: string = '';
let serviceNowServer: http.Server;
before(async () => {
const { server, url } = await getServiceNowSimulationServer();
serviceNowServer = server;
serviceNowSimulatorURL = url;
});
afterEach(async () => {
await deleteAllCaseItems(es);
await actionsRemover.removeAll();
});
after(async () => {
serviceNowServer.close();
});
it('connectors are counted in total_other_actions', async () => {
const [{ postedCase, connector: serviceNowConnector }, jiraConnector] = await Promise.all([
createCaseWithConnector({
supertest,
serviceNowSimulatorURL,
actionsRemover,
}),
createConnector({
supertest,
req: {
...getJiraConnector(),
},
}),
]);
actionsRemover.add('default', jiraConnector.id, 'action', 'actions');
const theCase = await pushCase({
supertest,
caseId: postedCase.id,
connectorId: serviceNowConnector.id,
});
await updateCase({
supertest,
params: {
cases: [
{
id: theCase.id,
version: theCase.version,
connector: {
id: jiraConnector.id,
name: 'Jira',
type: ConnectorTypes.jira,
fields: { issueType: 'Task', priority: null, parent: null },
},
},
],
},
});
const userActionTotals = await getCaseUserActionStats({ supertest, caseID: theCase.id });
expect(userActionTotals.total).to.equal(3);
expect(userActionTotals.total_comments).to.equal(0);
expect(userActionTotals.total_other_actions).to.equal(3);
expect(userActionTotals.total).to.equal(
userActionTotals.total_comments + userActionTotals.total_other_actions
);
});
it('assignees are counted in total_other_actions', async () => {
// 1 creation action
const theCase = await createCase(supertest, postCaseReq);
// 1 assignee action
await updateCase({
supertest,
params: {
cases: [
{
assignees: [
{
uid: '123',
},
],
id: theCase.id,
version: theCase.version,
},
],
},
});
const userActionTotals = await getCaseUserActionStats({ supertest, caseID: theCase.id });
expect(userActionTotals.total).to.equal(2);
expect(userActionTotals.total_comments).to.equal(0);
expect(userActionTotals.total_other_actions).to.equal(2);
expect(userActionTotals.total).to.equal(
userActionTotals.total_comments + userActionTotals.total_other_actions
);
});
});
};