Skip to content

Commit

Permalink
test: mls conference calls (#14674)
Browse files Browse the repository at this point in the history
* runfix: remove redundant set_epoch_info on req_clients callback

* runfix: dont call setclients for mls conference call

* chore: bump core

* runfix: dont call setClientsForConv for mls conference call

* test: test epoch info helper functions

* test: improve tests for mls conference calls

* test: improve test mocks

* test: improve mocks
  • Loading branch information
PatrykBuniX authored Feb 13, 2023
1 parent f0c777f commit ad106fc
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 47 deletions.
17 changes: 15 additions & 2 deletions src/__mocks__/@wireapp/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,28 @@
*
*/

export class Account {
import {EventEmitter} from 'stream';

export class Account extends EventEmitter {
backendFeatures = {
federationEndpoints: false,
};

configureMLSCallbacks = jest.fn();

service = {
mls: {registerConversation: jest.fn()},
mls: {
registerConversation: jest.fn(),
joinConferenceSubconversation: jest.fn(),
getGroupIdFromConversationId: jest.fn(),
renewKeyMaterial: jest.fn(),
getClientIds: jest.fn(),
getEpoch: jest.fn(),
exportSecretKey: jest.fn(),
on: this.on,
emit: this.emit,
off: this.off,
},
asset: {
uploadAsset: jest.fn(),
},
Expand Down
2 changes: 1 addition & 1 deletion src/script/calling/CallingRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ enum CALL_DIRECTION {
}

export interface SubconversationEpochInfoMember {
userid: string;
userid: `${string}@${string}`;
clientid: string;
in_subconv: boolean;
}
Expand Down
142 changes: 142 additions & 0 deletions src/script/view_model/CallingViewModel.mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* Wire
* Copyright (C) 2023 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {QualifiedId} from '@wireapp/api-client/lib/user';
import ko from 'knockout';
import {container} from 'tsyringe';

import {CALL_TYPE, CONV_TYPE} from '@wireapp/avs';

import {CallingViewModel} from './CallingViewModel';

import {Call} from '../calling/Call';
import {CallingRepository} from '../calling/CallingRepository';
import {CallState} from '../calling/CallState';
import {Core} from '../service/CoreSingleton';

export const mockCallingRepository = {
startCall: jest.fn(),
answerCall: jest.fn(),
leaveCall: jest.fn(),
onIncomingCall: jest.fn(),
onRequestClientsCallback: jest.fn(),
onRequestNewEpochCallback: jest.fn(),
onLeaveCall: jest.fn(),
setEpochInfo: jest.fn(),
} as unknown as CallingRepository;

export const callState = new CallState();

export function buildCall(conversationId: string | QualifiedId, convType = CONV_TYPE.ONEONONE) {
const qualifiedId = typeof conversationId === 'string' ? {id: conversationId, domain: ''} : conversationId;
return new Call({id: 'user1', domain: ''}, qualifiedId, convType, {} as any, CALL_TYPE.NORMAL, {
currentAvailableDeviceId: {audioOutput: ko.observable()},
} as any);
}

const mockCore = container.resolve(Core);

export function buildCallingViewModel() {
return new CallingViewModel(
mockCallingRepository,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
undefined,
callState,
undefined,
mockCore,
);
}

export const prepareMLSConferenceMocks = (parentGroupId: string, subGroupId: string) => {
const mockGetClientIdsResponses = {
[parentGroupId]: [
{userId: 'userId1', clientId: 'clientId1', domain: 'example.com'},
{userId: 'userId1', clientId: 'clientId1A', domain: 'example.com'},
{userId: 'userId2', clientId: 'clientId2', domain: 'example.com'},
{userId: 'userId2', clientId: 'clientId2A', domain: 'example.com'},
{userId: 'userId3', clientId: 'clientId3', domain: 'example.com'},
],
[subGroupId]: [
{userId: 'userId1', clientId: 'clientId1', domain: 'example.com'},
{userId: 'userId1', clientId: 'clientId1A', domain: 'example.com'},
{userId: 'userId2', clientId: 'clientId2', domain: 'example.com'},
],
};

const expectedMemberListResult = [
{
userid: 'userId1@example.com',
clientid: 'clientId1',
in_subconv: true,
},
{
userid: 'userId1@example.com',
clientid: 'clientId1A',
in_subconv: true,
},
{
userid: 'userId2@example.com',
clientid: 'clientId2',
in_subconv: true,
},
{
userid: 'userId2@example.com',
clientid: 'clientId2A',
in_subconv: false,
},
{
userid: 'userId3@example.com',
clientid: 'clientId3',
in_subconv: false,
},
];

const mockSecretKey = 'secretKey';
const mockEpochNumber = 1;
const mockKeyLength = 32;

jest
.spyOn(mockCore.service!.mls!, 'joinConferenceSubconversation')
.mockResolvedValue({epoch: mockEpochNumber, groupId: subGroupId});

jest
.spyOn(mockCore.service!.mls!, 'getGroupIdFromConversationId')
.mockImplementation((_conversationId, subconversationId) =>
subconversationId ? Promise.resolve(subGroupId) : Promise.resolve(parentGroupId),
);

jest
.spyOn(mockCore.service!.mls!, 'getClientIds')
.mockImplementation(groupId =>
Promise.resolve(mockGetClientIdsResponses[groupId as keyof typeof mockGetClientIdsResponses]),
);

jest.spyOn(mockCore.service!.mls!, 'getEpoch').mockImplementation(() => Promise.resolve(mockEpochNumber));

jest.spyOn(mockCore.service!.mls!, 'exportSecretKey').mockResolvedValue(mockSecretKey);

return {expectedMemberListResult, mockSecretKey, mockEpochNumber, mockKeyLength};
};
159 changes: 115 additions & 44 deletions src/script/view_model/CallingViewModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,60 +17,25 @@
*
*/

import ko from 'knockout';
import {waitFor} from '@testing-library/react';
import {ConversationProtocol} from '@wireapp/api-client/lib/conversation';

import {CALL_TYPE, CONV_TYPE, STATE} from '@wireapp/avs';

import {PrimaryModal} from 'Components/Modals/PrimaryModal';
import {createRandomUuid} from 'Util/util';

import {CallingViewModel} from './CallingViewModel';
import {
buildCall,
buildCallingViewModel,
callState,
mockCallingRepository,
prepareMLSConferenceMocks,
} from './CallingViewModel.mocks';

import {Call} from '../calling/Call';
import {CallingRepository} from '../calling/CallingRepository';
import {CallState} from '../calling/CallState';
import {LEAVE_CALL_REASON} from '../calling/enum/LeaveCallReason';
import {Conversation} from '../entity/Conversation';

const mockCallingRepository = {
startCall: jest.fn(),
answerCall: jest.fn(),
leaveCall: jest.fn(),
onIncomingCall: jest.fn(),
onRequestClientsCallback: jest.fn(),
onRequestNewEpochCallback: jest.fn(),
onLeaveCall: jest.fn(),
} as unknown as CallingRepository;

const callState = new CallState();

function buildCall(conversationId: string) {
return new Call(
{id: 'user1', domain: ''},
{id: conversationId, domain: ''},
CONV_TYPE.ONEONONE,
{} as any,
CALL_TYPE.NORMAL,
{currentAvailableDeviceId: {audioOutput: ko.observable()}} as any,
);
}

function buildCallingViewModel() {
return new CallingViewModel(
mockCallingRepository,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
undefined,
callState,
);
}

describe('CallingViewModel', () => {
afterEach(() => {
callState.calls.removeAll();
Expand Down Expand Up @@ -134,4 +99,110 @@ describe('CallingViewModel', () => {
expect(mockCallingRepository.startCall).toHaveBeenCalledWith(conversation, CALL_TYPE.NORMAL);
});
});

describe('MLS conference call', () => {
it('updates epoch info after initiating a call', async () => {
const mockParentGroupId = 'mockParentGroupId1';
const mockSubGroupId = 'mockSubGroupId1';
const {expectedMemberListResult, mockEpochNumber, mockKeyLength, mockSecretKey} = prepareMLSConferenceMocks(
mockParentGroupId,
mockSubGroupId,
);

const callingViewModel = buildCallingViewModel();
const conversationId = {domain: 'example.com', id: 'conversation1'};
const mlsConversation = new Conversation(conversationId.id, conversationId.domain, ConversationProtocol.MLS);

const mockedCall = buildCall(conversationId, CONV_TYPE.CONFERENCE_MLS);
jest.spyOn(mockCallingRepository, 'startCall').mockResolvedValueOnce(mockedCall);

await callingViewModel.callActions.startAudio(mlsConversation);

expect(mockCallingRepository.startCall).toHaveBeenCalledWith(mlsConversation, CALL_TYPE.NORMAL);

expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledWith(
conversationId,
{
epoch: mockEpochNumber,
keyLength: mockKeyLength,
secretKey: mockSecretKey,
},
expectedMemberListResult,
);
});

it('updates epoch info after answering a call', async () => {
const mockParentGroupId = 'mockParentGroupId2';
const mockSubGroupId = 'mockSubGroupId2';
const {expectedMemberListResult, mockEpochNumber, mockKeyLength, mockSecretKey} = prepareMLSConferenceMocks(
mockParentGroupId,
mockSubGroupId,
);

const callingViewModel = buildCallingViewModel();
const conversationId = {domain: 'example.com', id: 'conversation1'};

const call = buildCall(conversationId, CONV_TYPE.CONFERENCE_MLS);

await callingViewModel.callActions.answer(call);

expect(mockCallingRepository.answerCall).toHaveBeenCalledWith(call);

expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledWith(
conversationId,
{
epoch: mockEpochNumber,
keyLength: mockKeyLength,
secretKey: mockSecretKey,
},
expectedMemberListResult,
);
});

it('updates epoch info after mls service has emmited "newEpoch" event', async () => {
const mockParentGroupId = 'mockParentGroupId3';
const mockSubGroupId = 'mockSubGroupId3';
const {expectedMemberListResult, mockEpochNumber, mockKeyLength, mockSecretKey} = prepareMLSConferenceMocks(
mockParentGroupId,
mockSubGroupId,
);

const callingViewModel = buildCallingViewModel();
const conversationId = {domain: 'example.com', id: 'conversation1'};
const call = buildCall(conversationId, CONV_TYPE.CONFERENCE_MLS);

await callingViewModel.callActions.answer(call);
expect(mockCallingRepository.answerCall).toHaveBeenCalledWith(call);

//at this point we start to listen to the mls service events
expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledWith(
conversationId,
{
epoch: mockEpochNumber,
keyLength: mockKeyLength,
secretKey: mockSecretKey,
},
expectedMemberListResult,
);

const newEpochNumber = 2;
callingViewModel.mlsService.emit('newEpoch', {
epoch: newEpochNumber,
groupId: mockSubGroupId,
});

await waitFor(() => {
expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledTimes(2);
expect(mockCallingRepository.setEpochInfo).toHaveBeenCalledWith(
conversationId,
{
epoch: newEpochNumber,
keyLength: mockKeyLength,
secretKey: mockSecretKey,
},
expectedMemberListResult,
);
});
});
});
});

0 comments on commit ad106fc

Please sign in to comment.