// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; import { v4 as generateUuid } from 'uuid'; import { DataReader, DataWriter } from '../../sql/Client'; import { CallMode } from '../../types/Calling'; import { generateAci } from '../../types/ServiceId'; import type { ServiceIdString } from '../../types/ServiceId'; import type { CallHistoryDetails, CallHistoryGroup, CallStatus, } from '../../types/CallDisposition'; import { AdhocCallStatus, CallDirection, CallHistoryFilterStatus, CallType, DirectCallStatus, } from '../../types/CallDisposition'; import { strictAssert } from '../../util/assert'; import type { ConversationAttributesType } from '../../model-types'; import { FAKE_CALL_LINK, FAKE_CALL_LINK_WITH_ADMIN_KEY, } from '../../test-both/helpers/fakeCallLink'; const { getCallHistoryGroups, getCallHistoryGroupsCount } = DataReader; const { removeAll, insertCallLink, saveCallHistory, saveConversation } = DataWriter; function toGroup(calls: Array): CallHistoryGroup { const firstCall = calls.at(0); strictAssert(firstCall != null, 'needs at least 1 item'); return { peerId: firstCall.peerId, mode: firstCall.mode, type: firstCall.type, direction: firstCall.direction, timestamp: firstCall.timestamp, status: firstCall.status, children: calls.map(call => { return { callId: call.callId, timestamp: call.timestamp }; }), }; } function toAdhocGroup(call: CallHistoryDetails): CallHistoryGroup { strictAssert(call != null, 'needs call'); return { peerId: call.peerId, mode: call.mode, type: call.type, direction: call.direction, timestamp: call.timestamp, status: call.status, children: [ { callId: call.callId, timestamp: call.timestamp, }, ], }; } describe('sql/getCallHistoryGroups', () => { beforeEach(async () => { await removeAll(); }); it('should merge related items in order', async () => { const now = Date.now(); const conversationId = generateUuid(); function toCall(callId: string, timestamp: number) { return { callId, peerId: conversationId, ringerId: generateAci(), mode: CallMode.Direct, type: CallType.Video, direction: CallDirection.Incoming, timestamp, status: DirectCallStatus.Accepted, }; } const call1 = toCall('1', now - 10); const call2 = toCall('2', now); await saveCallHistory(call1); await saveCallHistory(call2); const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: null, }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toGroup([call2, call1])]); }); it('should separate unrelated items in order', async () => { const now = Date.now(); const conversationId = generateUuid(); function toCall(callId: string, timestamp: number, type: CallType) { return { callId, peerId: conversationId, ringerId: generateAci(), mode: CallMode.Direct, type, direction: CallDirection.Incoming, timestamp, status: DirectCallStatus.Accepted, }; } const call1 = toCall('1', now - 10, CallType.Video); const call2 = toCall('2', now, CallType.Audio); await saveCallHistory(call1); await saveCallHistory(call2); const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: null, }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toGroup([call2]), toGroup([call1])]); }); it('should split groups that are contiguous', async () => { const now = Date.now(); const conversationId = generateUuid(); function toCall(callId: string, timestamp: number, type: CallType) { return { callId, peerId: conversationId, ringerId: generateAci(), mode: CallMode.Direct, type, direction: CallDirection.Incoming, timestamp, status: DirectCallStatus.Accepted, }; } const call1 = toCall('1', now - 30, CallType.Video); const call2 = toCall('2', now - 20, CallType.Video); const call3 = toCall('3', now - 10, CallType.Audio); const call4 = toCall('4', now, CallType.Video); await saveCallHistory(call1); await saveCallHistory(call2); await saveCallHistory(call3); await saveCallHistory(call4); const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: null, }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [ toGroup([call4]), toGroup([call3]), toGroup([call2, call1]), ]); }); it('should search in the correct conversations', async () => { const now = Date.now(); const conversation1Uuid = generateAci(); const conversation2GroupId = 'groupId:2'; const conversation1: ConversationAttributesType = { type: 'private', version: 0, id: 'id:1', serviceId: conversation1Uuid, }; const conversation2: ConversationAttributesType = { type: 'group', version: 2, id: 'id:2', groupId: conversation2GroupId, }; await saveConversation(conversation1); await saveConversation(conversation2); function toCall( callId: string, timestamp: number, mode: CallMode, peerId: string | ServiceIdString ) { return { callId, peerId, ringerId: null, mode, type: CallType.Video, direction: CallDirection.Incoming, timestamp, status: DirectCallStatus.Accepted, }; } const call1 = toCall('1', now - 10, CallMode.Direct, conversation1Uuid); const call2 = toCall('2', now, CallMode.Group, conversation2GroupId); await saveCallHistory(call1); await saveCallHistory(call2); { const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: [conversation1.id], }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toGroup([call1])]); } { const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: [conversation2.id], }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toGroup([call2])]); } }); it('should support legacy call history with conversation.id', async () => { const now = Date.now(); const conversationId = generateUuid(); const conversation: ConversationAttributesType = { type: 'private', version: 0, id: conversationId, }; await saveConversation(conversation); const call = { callId: '1', peerId: conversationId, ringerId: null, mode: CallMode.Direct, type: CallType.Video, direction: CallDirection.Incoming, timestamp: now, status: DirectCallStatus.Accepted, }; await saveCallHistory(call); const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: [conversation.id], }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toGroup([call])]); }); it('should support Missed status filter', async () => { const now = Date.now(); const conversationId = generateUuid(); function toCall( callId: string, timestamp: number, type: CallType, status: CallStatus ) { return { callId, peerId: conversationId, ringerId: generateAci(), mode: CallMode.Direct, type, direction: CallDirection.Incoming, timestamp, status, }; } const call1 = toCall( '1', now - 10, CallType.Video, DirectCallStatus.Accepted ); const call2 = toCall('2', now, CallType.Audio, DirectCallStatus.Missed); await saveCallHistory(call1); await saveCallHistory(call2); const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.Missed, callLinkRoomIds: null, conversationIds: null, }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toGroup([call2])]); }); it('should only return the newest call for an adhoc call roomId', async () => { const now = Date.now(); const roomId = generateUuid(); function toCall(callId: string, timestamp: number) { return { callId, peerId: roomId, ringerId: null, mode: CallMode.Adhoc, type: CallType.Adhoc, direction: CallDirection.Incoming, timestamp, status: AdhocCallStatus.Joined, }; } const call1 = toCall('1', now - 10); const call2 = toCall('2', now); await saveCallHistory(call1); await saveCallHistory(call2); const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: null, }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toAdhocGroup(call2)]); }); it('should search call links', async () => { const now = Date.now(); const { roomId: roomId1 } = FAKE_CALL_LINK; const { roomId: roomId2 } = FAKE_CALL_LINK_WITH_ADMIN_KEY; await insertCallLink(FAKE_CALL_LINK); await insertCallLink(FAKE_CALL_LINK_WITH_ADMIN_KEY); const conversation1Uuid = generateAci(); const conversation2GroupId = 'groupId:2'; const conversation1: ConversationAttributesType = { type: 'private', version: 0, id: 'id:1', serviceId: conversation1Uuid, }; const conversation2: ConversationAttributesType = { type: 'group', version: 2, id: 'id:2', groupId: conversation2GroupId, }; await saveConversation(conversation1); await saveConversation(conversation2); function toAdhocCall(callId: string, roomId: string, timestamp: number) { return { callId, peerId: roomId, ringerId: null, mode: CallMode.Adhoc, type: CallType.Adhoc, direction: CallDirection.Outgoing, timestamp, status: AdhocCallStatus.Joined, }; } function toConversationCall( callId: string, timestamp: number, mode: CallMode, peerId: string | ServiceIdString ) { return { callId, peerId, ringerId: null, mode, type: CallType.Video, direction: CallDirection.Incoming, timestamp, status: DirectCallStatus.Accepted, }; } const call1 = toAdhocCall('1', roomId1, now - 30); const call2 = toAdhocCall('2', roomId2, now - 20); const call3 = toConversationCall( '3', now - 10, CallMode.Direct, conversation1Uuid ); const call4 = toConversationCall( '4', now, CallMode.Group, conversation2GroupId ); await saveCallHistory(call1); await saveCallHistory(call2); await saveCallHistory(call3); await saveCallHistory(call4); { const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: [roomId1], conversationIds: null, }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toAdhocGroup(call1)], 'just call link'); } { const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, callLinkRoomIds: [roomId2], conversationIds: [conversation2.id], }, { offset: 0, limit: 0 } ); assert.deepEqual( groups, [toGroup([call4]), toAdhocGroup(call2)], 'call link and conversation' ); } }); }); describe('sql/getCallHistoryGroupsCount', () => { beforeEach(async () => { await removeAll(); }); it('counts', async () => { const now = Date.now(); const conversationId = generateUuid(); function toCall(callId: string, timestamp: number, type: CallType) { return { callId, peerId: conversationId, ringerId: generateAci(), mode: CallMode.Direct, type, direction: CallDirection.Incoming, timestamp, status: DirectCallStatus.Accepted, }; } const call1 = toCall('1', now - 30, CallType.Video); const call2 = toCall('2', now - 20, CallType.Video); const call3 = toCall('3', now - 10, CallType.Audio); const call4 = toCall('4', now, CallType.Video); await saveCallHistory(call1); await saveCallHistory(call2); await saveCallHistory(call3); await saveCallHistory(call4); const result = await getCallHistoryGroupsCount({ status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: null, }); assert.equal(result, 3); }); it('should only count each call link roomId once if it had multiple calls', async () => { const now = Date.now(); const roomId1 = generateUuid(); const roomId2 = generateUuid(); function toCall(callId: string, roomId: string, timestamp: number) { return { callId, peerId: roomId, ringerId: null, mode: CallMode.Adhoc, type: CallType.Adhoc, direction: CallDirection.Outgoing, timestamp, status: AdhocCallStatus.Joined, }; } const call1 = toCall('1', roomId1, now - 20); const call2 = toCall('2', roomId1, now - 10); const call3 = toCall('3', roomId1, now); const call4 = toCall('4', roomId2, now); await saveCallHistory(call1); await saveCallHistory(call2); await saveCallHistory(call3); await saveCallHistory(call4); const result = await getCallHistoryGroupsCount({ status: CallHistoryFilterStatus.All, callLinkRoomIds: null, conversationIds: null, }); assert.equal(result, 2); }); });