1004 lines
34 KiB
TypeScript
1004 lines
34 KiB
TypeScript
import {
|
|
Injector,
|
|
runInInjectionContext,
|
|
signal,
|
|
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
|
|
ɵEffectScheduler as EffectScheduler
|
|
} from '@angular/core';
|
|
import { Router } from '@angular/router';
|
|
import { Store } from '@ngrx/store';
|
|
import { Subject } from 'rxjs';
|
|
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
|
import {
|
|
MobileCallSessionService,
|
|
MobileMediaService,
|
|
MobileNotificationsService
|
|
} from '../../../../infrastructure/mobile';
|
|
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
|
|
import { ViewportService } from '../../../../core/platform';
|
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
|
import {
|
|
VoiceActivityService,
|
|
VoiceConnectionFacade,
|
|
VoicePlaybackService
|
|
} from '../../../voice-connection';
|
|
import { VoiceSessionFacade } from '../../../voice-session';
|
|
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
|
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
import type {
|
|
ChatEvent,
|
|
DirectMessageParticipant,
|
|
User
|
|
} from '../../../../shared-kernel';
|
|
import type { DirectMessageConversation } from '../../../direct-message';
|
|
import type { DirectCallSession } from '../../domain/models/direct-call.model';
|
|
import { DirectCallService } from './direct-call.service';
|
|
|
|
const alice = createUser('alice', 'Alice');
|
|
const bob = createUser('bob', 'Bob');
|
|
const charlie = createUser('charlie', 'Charlie');
|
|
|
|
describe('DirectCallService', () => {
|
|
it('only keeps sessions visible while a participant is joined', () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
|
|
|
expect(context.service.hasOngoingActivity(createSession('calling', false))).toBe(false);
|
|
expect(context.service.hasOngoingActivity(createSession('ringing', false))).toBe(false);
|
|
expect(context.service.hasOngoingActivity(createSession('connected', true))).toBe(true);
|
|
expect(context.service.hasOngoingActivity(createSession('connected', false))).toBe(false);
|
|
expect(context.service.hasOngoingActivity(createSession('ended', true))).toBe(false);
|
|
});
|
|
|
|
it('keeps a locally left call visible only until the last peer leaves', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
|
const session = createSession('connected', true);
|
|
|
|
session.participants.bob.joined = true;
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
|
|
|
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
|
|
|
context.service.leaveCall(session.callId);
|
|
|
|
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
|
|
|
context.directCallEvents.next(createCallEvent('leave', bob, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
|
|
});
|
|
|
|
it('hides an incoming call after the last joined participant leaves before answer', async () => {
|
|
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(1));
|
|
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
|
|
expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob');
|
|
|
|
context.directCallEvents.next(createCallEvent('leave', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
|
|
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
|
expect(context.service.incomingCall()).toBeNull();
|
|
});
|
|
|
|
it('suppresses incoming call audio and modal state while do not disturb is active', async () => {
|
|
const busyBob = { ...bob, status: 'busy' as const };
|
|
const context = createServiceContext({ currentUser: busyBob, allUsers: [alice, busyBob] });
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).not.toBeNull());
|
|
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
|
await vi.waitFor(() => expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call));
|
|
expect(context.service.incomingCall()).toBeNull();
|
|
});
|
|
|
|
it('ignores incoming call events when the current user is not a participant', async () => {
|
|
const context = createServiceContext({ currentUser: charlie, allUsers: [
|
|
alice,
|
|
bob,
|
|
charlie
|
|
] });
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).toBeNull());
|
|
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
|
expect(context.directMessages.createConversation).not.toHaveBeenCalled();
|
|
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('notifies when a ring addresses the local user via a provisioned signal-server actor id', async () => {
|
|
// Bob's home identity is "bob", but on the caller's signal server he acts
|
|
// through the provisioned identity "bob-actor". The ring payload only
|
|
// carries the actor id, so admission must match every local alias.
|
|
const context = createServiceContext({
|
|
currentUser: bob,
|
|
allUsers: [alice, bob],
|
|
selfActorIds: ['bob-actor']
|
|
});
|
|
|
|
context.directCallEvents.next({
|
|
type: 'direct-call',
|
|
directCall: {
|
|
action: 'ring',
|
|
callId: 'dm-alice-bob-actor',
|
|
conversationId: 'dm-alice-bob-actor',
|
|
createdAt: 10,
|
|
sender: toParticipant(alice),
|
|
participantIds: ['alice', 'bob-actor'],
|
|
participants: [toParticipant(alice), { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }]
|
|
}
|
|
});
|
|
|
|
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob-actor'));
|
|
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
|
|
|
|
const session = context.service.sessionById('dm-alice-bob-actor');
|
|
|
|
// The actor alias must collapse onto the local user instead of appearing
|
|
// as a third participant (which would convert the call into a group chat).
|
|
expect(session?.participantIds.sort()).toEqual(['alice', 'bob']);
|
|
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores rings echoed back to the sender through a provisioned actor alias', async () => {
|
|
const context = createServiceContext({
|
|
currentUser: bob,
|
|
allUsers: [alice, bob],
|
|
selfActorIds: ['bob-actor']
|
|
});
|
|
|
|
context.directCallEvents.next({
|
|
type: 'direct-call',
|
|
directCall: {
|
|
action: 'ring',
|
|
callId: 'dm-alice-bob',
|
|
conversationId: 'dm-alice-bob',
|
|
createdAt: 10,
|
|
sender: { userId: 'bob-actor', username: 'bob', displayName: 'Bob' },
|
|
participantIds: ['alice', 'bob-actor'],
|
|
participants: [toParticipant(alice), { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }]
|
|
}
|
|
});
|
|
|
|
await Promise.resolve();
|
|
expect(context.service.sessionById('dm-alice-bob')).toBeNull();
|
|
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('excludes provisioned actor aliases from remote participant ids', () => {
|
|
const context = createServiceContext({
|
|
currentUser: bob,
|
|
allUsers: [alice, bob],
|
|
selfActorIds: ['bob-actor']
|
|
});
|
|
|
|
expect(context.service.remoteParticipantIds({
|
|
...createSession('ringing', false),
|
|
participantIds: [
|
|
'alice',
|
|
'bob',
|
|
'bob-actor'
|
|
]
|
|
})).toEqual(['alice']);
|
|
});
|
|
|
|
it('starts a DM-header call to the peer even when the conversation stores the local user under an actor alias', async () => {
|
|
const context = createServiceContext({
|
|
currentUser: bob,
|
|
allUsers: [alice, bob],
|
|
selfActorIds: ['bob-actor']
|
|
});
|
|
const conversation: DirectMessageConversation = {
|
|
id: 'dm-alice-bob-actor',
|
|
kind: 'direct',
|
|
lastMessageAt: 10,
|
|
messages: [],
|
|
participantProfiles: {
|
|
'alice': toParticipant(alice),
|
|
'bob-actor': { userId: 'bob-actor', username: 'bob', displayName: 'Bob' }
|
|
},
|
|
participants: ['alice', 'bob-actor'],
|
|
unreadCount: 0
|
|
};
|
|
|
|
context.service.joinCall = vi.fn(async () => undefined);
|
|
|
|
await context.service.startConversationCall(conversation);
|
|
|
|
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
|
|
directCall: expect.objectContaining({ action: 'ring' }),
|
|
type: 'direct-call'
|
|
}));
|
|
});
|
|
|
|
it('marks a remote join against the session participant alias stored locally', async () => {
|
|
const aliceForeign = createUser('alice-foreign', 'Alice');
|
|
const bobForeign = createUser('bob-foreign', 'Bob');
|
|
const bobHome = { ...bobForeign, id: 'bob-home', oderId: 'bob-home' };
|
|
const context = createServiceContext({ currentUser: aliceForeign, allUsers: [aliceForeign, bobForeign] });
|
|
|
|
context.directCallEvents.next({
|
|
type: 'direct-call',
|
|
directCall: {
|
|
action: 'ring',
|
|
callId: 'dm-alice-foreign--bob-foreign',
|
|
conversationId: 'dm-alice-foreign--bob-foreign',
|
|
createdAt: 10,
|
|
sender: toParticipant(bobForeign),
|
|
participantIds: ['alice-foreign', 'bob-foreign'],
|
|
participants: [toParticipant(aliceForeign), toParticipant(bobForeign)]
|
|
}
|
|
});
|
|
|
|
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-foreign--bob-foreign')).not.toBeNull());
|
|
|
|
context.directCallEvents.next({
|
|
type: 'direct-call',
|
|
directCall: {
|
|
action: 'join',
|
|
callId: 'dm-alice-foreign--bob-foreign',
|
|
conversationId: 'dm-alice-foreign--bob-foreign',
|
|
createdAt: 10,
|
|
sender: toParticipant(bobHome),
|
|
participantIds: ['alice-foreign', 'bob-foreign'],
|
|
participants: [toParticipant(aliceForeign), toParticipant(bobForeign)]
|
|
}
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
const session = context.service.sessionById('dm-alice-foreign--bob-foreign');
|
|
|
|
expect(session?.participants['bob-foreign']?.joined).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('answers an incoming call from the modal action', async () => {
|
|
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
|
await context.service.answerIncomingCall('dm-alice-bob');
|
|
|
|
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
|
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
|
|
expect(context.service.incomingCall()).toBeNull();
|
|
});
|
|
|
|
it('declines an incoming call from the modal action', async () => {
|
|
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
|
context.service.declineIncomingCall('dm-alice-bob');
|
|
|
|
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
|
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
|
|
directCall: expect.objectContaining({
|
|
action: 'leave',
|
|
callId: 'dm-alice-bob'
|
|
}),
|
|
type: 'direct-call'
|
|
}));
|
|
|
|
expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended');
|
|
expect(context.service.incomingCall()).toBeNull();
|
|
});
|
|
|
|
it('does not start ringing after declining while incoming ring setup is still pending', async () => {
|
|
let releaseCallLog: (() => void) | null = null;
|
|
|
|
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
|
|
|
context.directMessages.recordCallStarted.mockImplementationOnce(async () => new Promise<void>((resolve) => {
|
|
releaseCallLog = resolve;
|
|
}));
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
|
await vi.waitFor(() => expect(context.directMessages.recordCallStarted).toHaveBeenCalled());
|
|
context.service.declineIncomingCall('dm-alice-bob');
|
|
releaseCallLog?.();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(context.service.incomingCall()).toBeNull();
|
|
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
|
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
|
|
});
|
|
|
|
it('logs a system call-started entry when starting a direct PM call', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
|
|
|
await context.service.startCall(bob);
|
|
|
|
expect(context.directMessages.recordCallStarted).toHaveBeenCalledWith(
|
|
'dm-alice-bob',
|
|
expect.objectContaining({ userId: 'alice' }),
|
|
expect.arrayContaining([expect.objectContaining({ userId: 'alice' }), expect.objectContaining({ userId: 'bob' })]),
|
|
expect.any(Number)
|
|
);
|
|
});
|
|
|
|
it('logs a system call-started entry when receiving a PM ring', async () => {
|
|
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
|
await vi.waitFor(() => expect(context.directMessages.recordCallStarted).toHaveBeenCalledWith(
|
|
'dm-alice-bob',
|
|
expect.objectContaining({ userId: 'alice' }),
|
|
expect.arrayContaining([expect.objectContaining({ userId: 'alice' }), expect.objectContaining({ userId: 'bob' })]),
|
|
10
|
|
));
|
|
});
|
|
|
|
it('does not restart the call sound when a stale ring arrives after declining', async () => {
|
|
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
|
context.service.declineIncomingCall('dm-alice-bob');
|
|
context.audio.playLoop.mockClear();
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended'));
|
|
expect(context.service.incomingCall()).toBeNull();
|
|
expect(context.audio.playLoop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows a pending incoming call once the current user hydrates', async () => {
|
|
const context = createServiceContext({ currentUser: null, allUsers: [alice, bob] });
|
|
|
|
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
|
|
|
|
await Promise.resolve();
|
|
expect(context.service.sessionById('dm-alice-bob')).toBeNull();
|
|
|
|
context.currentUser.set(bob);
|
|
context.effectScheduler.flush();
|
|
|
|
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
|
|
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
|
|
});
|
|
|
|
it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
|
const session = createSession('connected', true);
|
|
|
|
session.participants.alice.joined = false;
|
|
session.participants.bob.joined = true;
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
|
context.service.joinCall = vi.fn(async () => undefined);
|
|
|
|
await context.service.startCall(bob);
|
|
|
|
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
|
expect(context.service.joinCall).toHaveBeenCalledWith('dm-alice-bob');
|
|
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
|
|
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
|
|
});
|
|
|
|
it('reuses an existing group call by conversation id instead of creating a duplicate call', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [
|
|
alice,
|
|
bob,
|
|
charlie
|
|
] });
|
|
const session = createGroupSession('dm-original-call', 'dm-group-live', [
|
|
alice,
|
|
bob,
|
|
charlie
|
|
]);
|
|
const conversation = createGroupConversation('dm-group-live', [
|
|
alice,
|
|
bob,
|
|
charlie
|
|
]);
|
|
|
|
session.participants.alice.joined = false;
|
|
session.participants.bob.joined = true;
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
|
context.service.joinCall = vi.fn(async () => undefined);
|
|
|
|
await context.service.startConversationCall(conversation);
|
|
|
|
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
|
expect(context.service.joinCall).toHaveBeenCalledWith('dm-original-call');
|
|
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
|
|
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
|
|
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-original-call']);
|
|
});
|
|
|
|
it('leaves a joined call before joining a different call', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [
|
|
alice,
|
|
bob,
|
|
charlie
|
|
] });
|
|
const firstSession = createSession('connected', true);
|
|
const nextSession = createDirectSession('dm-alice-charlie', alice, charlie, 'connected', false);
|
|
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(firstSession);
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(nextSession);
|
|
|
|
await context.service.joinCall(nextSession.callId);
|
|
|
|
expect(context.service.sessionById(firstSession.callId)?.participants.alice.joined).toBe(false);
|
|
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
|
|
directCall: expect.objectContaining({
|
|
action: 'leave',
|
|
callId: firstSession.callId
|
|
}),
|
|
type: 'direct-call'
|
|
}));
|
|
});
|
|
|
|
it('disconnects the current voice channel before joining a call', async () => {
|
|
const voiceConnectedAlice: User = {
|
|
...alice,
|
|
voiceState: {
|
|
isConnected: true,
|
|
isMuted: false,
|
|
isDeafened: false,
|
|
roomId: 'voice-room-1',
|
|
serverId: 'server-1'
|
|
}
|
|
};
|
|
const context = createServiceContext({ currentUser: voiceConnectedAlice, allUsers: [voiceConnectedAlice, bob] });
|
|
const session = createSession('connected', false);
|
|
|
|
session.participants.bob.joined = true;
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
|
|
|
await context.service.joinCall(session.callId);
|
|
|
|
expect(context.voice.stopVoiceHeartbeat).toHaveBeenCalled();
|
|
expect(context.voice.disableVoice).toHaveBeenCalled();
|
|
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'voice-state',
|
|
voiceState: expect.objectContaining({
|
|
isConnected: false,
|
|
roomId: 'voice-room-1',
|
|
serverId: 'server-1'
|
|
})
|
|
}));
|
|
|
|
expect(context.voiceSession.endSession).toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces a join error when the microphone permission is denied instead of failing silently', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
|
const session = createSession('connected', false);
|
|
|
|
session.participants.bob.joined = true;
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
|
|
|
context.mobileMedia.ensureVoiceCapturePermissions.mockResolvedValue(false);
|
|
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
|
|
await context.service.joinCall(session.callId);
|
|
});
|
|
|
|
expect(context.service.joinError()).not.toBeNull();
|
|
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
|
|
expect(context.service.sessionById(session.callId)?.participants.alice.joined).not.toBe(true);
|
|
});
|
|
|
|
it('surfaces a join error when getUserMedia rejects', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
|
const session = createSession('connected', false);
|
|
|
|
session.participants.bob.joined = true;
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
|
|
|
await withStubbedGetUserMedia(vi.fn(async () => {
|
|
throw new Error('NotReadableError');
|
|
}), async () => {
|
|
await context.service.joinCall(session.callId);
|
|
});
|
|
|
|
expect(context.service.joinError()).not.toBeNull();
|
|
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears the join error on a successful join', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
|
const session = createSession('connected', false);
|
|
|
|
session.participants.bob.joined = true;
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
|
|
|
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
|
|
await context.service.joinCall(session.callId);
|
|
});
|
|
|
|
expect(context.service.joinError()).toBeNull();
|
|
expect(context.voice.setLocalStream).toHaveBeenCalled();
|
|
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
|
|
});
|
|
|
|
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
|
|
const context = createServiceContext({ currentUser: alice, allUsers: [
|
|
alice,
|
|
bob,
|
|
charlie
|
|
] });
|
|
const conversation = createGroupConversation('dm-group-test', [
|
|
alice,
|
|
bob,
|
|
charlie
|
|
]);
|
|
|
|
context.service.joinCall = vi.fn(async (callId: string) => {
|
|
const session = context.service.sessionById(callId);
|
|
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession({
|
|
...session,
|
|
status: 'connected',
|
|
participants: {
|
|
...session.participants,
|
|
alice: {
|
|
...session.participants.alice,
|
|
joined: true
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
await context.service.startConversationCall(conversation);
|
|
|
|
expect(context.service.visibleActiveSessions()).toHaveLength(1);
|
|
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
|
|
directCall: expect.objectContaining({
|
|
action: 'ring',
|
|
callId: 'dm-group-test'
|
|
}),
|
|
type: 'direct-call'
|
|
}));
|
|
|
|
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('charlie', expect.objectContaining({
|
|
directCall: expect.objectContaining({
|
|
action: 'ring',
|
|
callId: 'dm-group-test'
|
|
}),
|
|
type: 'direct-call'
|
|
}));
|
|
|
|
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-group-test']);
|
|
});
|
|
});
|
|
|
|
interface ServiceContextOptions {
|
|
allUsers: User[];
|
|
currentUser: User | null;
|
|
selfActorIds?: string[];
|
|
}
|
|
|
|
interface ServiceContext {
|
|
audio: {
|
|
playLoop: ReturnType<typeof vi.fn>;
|
|
stop: ReturnType<typeof vi.fn>;
|
|
};
|
|
currentUser: ReturnType<typeof signal<User | null>>;
|
|
delivery: {
|
|
sendCallEvent: ReturnType<typeof vi.fn>;
|
|
};
|
|
directCallEvents: Subject<ChatEvent>;
|
|
directMessages: {
|
|
createConversation: ReturnType<typeof vi.fn>;
|
|
createGroupConversation: ReturnType<typeof vi.fn>;
|
|
openConversation: ReturnType<typeof vi.fn>;
|
|
recordCallStarted: ReturnType<typeof vi.fn>;
|
|
};
|
|
effectScheduler: {
|
|
flush: ReturnType<typeof vi.fn>;
|
|
};
|
|
router: {
|
|
navigate: ReturnType<typeof vi.fn>;
|
|
};
|
|
service: DirectCallService;
|
|
voice: {
|
|
broadcastMessage: ReturnType<typeof vi.fn>;
|
|
disableVoice: ReturnType<typeof vi.fn>;
|
|
stopVoiceHeartbeat: ReturnType<typeof vi.fn>;
|
|
};
|
|
voiceSession: {
|
|
endSession: ReturnType<typeof vi.fn>;
|
|
};
|
|
}
|
|
|
|
function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|
const currentUser = signal<User | null>(options.currentUser);
|
|
const allUsers = signal<User[]>(options.allUsers);
|
|
const directCallEvents = new Subject<ChatEvent>();
|
|
const router = {
|
|
navigate: vi.fn(async () => true)
|
|
};
|
|
const store = {
|
|
dispatch: vi.fn(),
|
|
selectSignal: vi.fn((selector: unknown) => {
|
|
if (selector === selectCurrentUser) {
|
|
return currentUser;
|
|
}
|
|
|
|
if (selector === selectAllUsers) {
|
|
return allUsers;
|
|
}
|
|
|
|
throw new Error('Unexpected selector requested by DirectCallService test.');
|
|
})
|
|
};
|
|
const directMessages = {
|
|
createConversation: vi.fn(async (user: User) => createDirectConversation(currentUser() ?? bob, user)),
|
|
createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({
|
|
...createGroupConversation(conversationId, participants.map(participantToUser)),
|
|
title
|
|
})),
|
|
openConversation: vi.fn(async () => undefined),
|
|
recordCallStarted: vi.fn(async () => undefined)
|
|
};
|
|
const delivery = {
|
|
directCallEvents$: directCallEvents.asObservable(),
|
|
sendCallEvent: vi.fn(() => true)
|
|
};
|
|
const audio = {
|
|
playLoop: vi.fn(),
|
|
stop: vi.fn()
|
|
};
|
|
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
|
|
const effectScheduler = {
|
|
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
|
scheduledEffects.add(scheduledEffect);
|
|
}),
|
|
flush: vi.fn(() => {
|
|
for (const scheduledEffect of scheduledEffects) {
|
|
if (scheduledEffect.dirty) {
|
|
scheduledEffect.run();
|
|
}
|
|
}
|
|
}),
|
|
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
|
scheduledEffects.delete(scheduledEffect);
|
|
}),
|
|
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
|
scheduledEffects.add(scheduledEffect);
|
|
})
|
|
};
|
|
const voice = {
|
|
broadcastMessage: vi.fn(),
|
|
disableVoice: vi.fn(),
|
|
ensureSignalingConnected: vi.fn(async () => true),
|
|
isDeafened: vi.fn(() => false),
|
|
isMuted: vi.fn(() => false),
|
|
setLocalStream: vi.fn(async () => undefined),
|
|
startVoiceHeartbeat: vi.fn(),
|
|
stopVoiceHeartbeat: vi.fn(),
|
|
syncOutgoingVoiceRouting: vi.fn(),
|
|
toggleMute: vi.fn()
|
|
};
|
|
const voiceSession = {
|
|
endSession: vi.fn()
|
|
};
|
|
const mobileMedia = {
|
|
ensureVoiceCapturePermissions: vi.fn(async () => true),
|
|
setSpeakerphoneEnabled: vi.fn(async () => undefined)
|
|
};
|
|
const credentialStore = {
|
|
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
|
|
serverUrl: `https://signal.example/${userId}`,
|
|
userId,
|
|
username: userId,
|
|
displayName: userId,
|
|
token: 'token',
|
|
expiresAt: Date.now() + 60_000,
|
|
provisioned: true
|
|
})))
|
|
};
|
|
const injector = Injector.create({
|
|
providers: [
|
|
{
|
|
provide: ChangeDetectionScheduler,
|
|
useValue: {
|
|
notify: vi.fn()
|
|
}
|
|
},
|
|
{
|
|
provide: EffectScheduler,
|
|
useValue: effectScheduler
|
|
},
|
|
{
|
|
provide: DirectMessageService,
|
|
useValue: directMessages
|
|
},
|
|
{
|
|
provide: NotificationAudioService,
|
|
useValue: audio
|
|
},
|
|
{
|
|
provide: PeerDeliveryService,
|
|
useValue: delivery
|
|
},
|
|
{
|
|
provide: Router,
|
|
useValue: router
|
|
},
|
|
{
|
|
provide: Store,
|
|
useValue: store
|
|
},
|
|
{
|
|
provide: VoiceActivityService,
|
|
useValue: {
|
|
trackLocalMic: vi.fn(),
|
|
untrackLocalMic: vi.fn()
|
|
}
|
|
},
|
|
{
|
|
provide: VoiceConnectionFacade,
|
|
useValue: voice
|
|
},
|
|
{
|
|
provide: VoiceSessionFacade,
|
|
useValue: voiceSession
|
|
},
|
|
{
|
|
provide: VoicePlaybackService,
|
|
useValue: {
|
|
playPendingStreams: vi.fn(),
|
|
teardownAll: vi.fn()
|
|
}
|
|
},
|
|
{
|
|
provide: ViewportService,
|
|
useValue: {
|
|
isMobile: vi.fn(() => false)
|
|
}
|
|
},
|
|
{
|
|
provide: MobileNotificationsService,
|
|
useValue: {
|
|
initialize: vi.fn(async () => undefined),
|
|
showIncomingCall: vi.fn(async () => undefined)
|
|
}
|
|
},
|
|
{
|
|
provide: MobileCallSessionService,
|
|
useValue: {
|
|
initialize: vi.fn(),
|
|
onCallControlAction: vi.fn(),
|
|
startActiveCall: vi.fn(async () => undefined),
|
|
endActiveCall: vi.fn(async () => undefined)
|
|
}
|
|
},
|
|
{
|
|
provide: MobileMediaService,
|
|
useValue: mobileMedia
|
|
},
|
|
{
|
|
provide: RealtimeSessionFacade,
|
|
useValue: {
|
|
getClientInstanceId: vi.fn(() => 'test-client'),
|
|
requestVoiceClientTakeover: vi.fn()
|
|
}
|
|
},
|
|
{
|
|
provide: SignalServerCredentialStoreService,
|
|
useValue: credentialStore
|
|
},
|
|
...provideAppI18nForTests()
|
|
]
|
|
});
|
|
|
|
initializeAppI18nForTests(injector);
|
|
|
|
return {
|
|
audio,
|
|
currentUser,
|
|
delivery,
|
|
directCallEvents,
|
|
directMessages,
|
|
effectScheduler,
|
|
mobileMedia,
|
|
router,
|
|
service: runInInjectionContext(injector, () => new DirectCallService()),
|
|
voice,
|
|
voiceSession
|
|
};
|
|
}
|
|
|
|
class FakeMediaStream {
|
|
getTracks(): unknown[] {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Temporarily provide `navigator.mediaDevices.getUserMedia` for the join path under Node. */
|
|
async function withStubbedGetUserMedia(
|
|
getUserMedia: () => Promise<unknown>,
|
|
run: () => Promise<void>
|
|
): Promise<void> {
|
|
const globalWithNavigator = globalThis as { navigator?: { mediaDevices?: unknown } };
|
|
const originalNavigator = globalWithNavigator.navigator;
|
|
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true,
|
|
value: {
|
|
...(originalNavigator ?? {}),
|
|
mediaDevices: { getUserMedia }
|
|
}
|
|
});
|
|
|
|
try {
|
|
await run();
|
|
} finally {
|
|
if (originalNavigator === undefined) {
|
|
delete globalWithNavigator.navigator;
|
|
} else {
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true,
|
|
value: originalNavigator
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
|
|
return {
|
|
type: 'direct-call',
|
|
directCall: {
|
|
action,
|
|
callId: 'dm-alice-bob',
|
|
conversationId: 'dm-alice-bob',
|
|
createdAt: 10,
|
|
sender: toParticipant(sender),
|
|
participantIds,
|
|
participants: [alice, bob].map(toParticipant)
|
|
}
|
|
};
|
|
}
|
|
|
|
function createSession(status: DirectCallSession['status'], joined: boolean): DirectCallSession {
|
|
return {
|
|
callId: 'dm-alice-bob',
|
|
conversationId: 'dm-alice-bob',
|
|
createdAt: 10,
|
|
initiatorId: 'alice',
|
|
participantIds: ['alice', 'bob'],
|
|
participants: {
|
|
alice: {
|
|
userId: 'alice',
|
|
profile: toParticipant(alice),
|
|
joined
|
|
},
|
|
bob: {
|
|
userId: 'bob',
|
|
profile: toParticipant(bob),
|
|
joined: false
|
|
}
|
|
},
|
|
status
|
|
};
|
|
}
|
|
|
|
function createDirectSession(
|
|
callId: string,
|
|
currentUser: User,
|
|
peer: User,
|
|
status: DirectCallSession['status'],
|
|
joined: boolean
|
|
): DirectCallSession {
|
|
const currentParticipant = toParticipant(currentUser);
|
|
const peerParticipant = toParticipant(peer);
|
|
|
|
return {
|
|
callId,
|
|
conversationId: callId,
|
|
createdAt: 10,
|
|
initiatorId: currentParticipant.userId,
|
|
participantIds: [currentParticipant.userId, peerParticipant.userId],
|
|
participants: {
|
|
[currentParticipant.userId]: {
|
|
userId: currentParticipant.userId,
|
|
profile: currentParticipant,
|
|
joined
|
|
},
|
|
[peerParticipant.userId]: {
|
|
userId: peerParticipant.userId,
|
|
profile: peerParticipant,
|
|
joined: false
|
|
}
|
|
},
|
|
status
|
|
};
|
|
}
|
|
|
|
function createGroupSession(callId: string, conversationId: string, users: User[]): DirectCallSession {
|
|
const participants = users.map(toParticipant);
|
|
|
|
return {
|
|
callId,
|
|
conversationId,
|
|
createdAt: 10,
|
|
initiatorId: participants[0].userId,
|
|
participantIds: participants.map((participant) => participant.userId),
|
|
participants: Object.fromEntries(participants.map((participant) => [
|
|
participant.userId,
|
|
{
|
|
userId: participant.userId,
|
|
profile: participant,
|
|
joined: false
|
|
}
|
|
])),
|
|
status: 'connected'
|
|
};
|
|
}
|
|
|
|
function createDirectConversation(currentUser: User, peer: User): DirectMessageConversation {
|
|
const participants = [toParticipant(currentUser), toParticipant(peer)];
|
|
const participantIds = participants.map((participant) => participant.userId).sort();
|
|
|
|
return {
|
|
id: `dm-${participantIds.join('-')}`,
|
|
kind: 'direct',
|
|
lastMessageAt: 10,
|
|
messages: [],
|
|
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
|
|
participants: participantIds,
|
|
unreadCount: 0
|
|
};
|
|
}
|
|
|
|
function createGroupConversation(conversationId: string, users: User[]): DirectMessageConversation {
|
|
const participants = users.map(toParticipant);
|
|
const participantIds = participants.map((participant) => participant.userId).sort();
|
|
|
|
return {
|
|
id: conversationId,
|
|
kind: 'group',
|
|
lastMessageAt: 10,
|
|
messages: [],
|
|
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
|
|
participants: participantIds,
|
|
title: participants.map((participant) => participant.displayName).join(', '),
|
|
unreadCount: 0
|
|
};
|
|
}
|
|
|
|
function participantToUser(participant: DirectMessageParticipant): User {
|
|
return createUser(participant.userId, participant.displayName);
|
|
}
|
|
|
|
function toParticipant(user: User): DirectMessageParticipant {
|
|
return {
|
|
userId: user.oderId || user.id,
|
|
username: user.username,
|
|
displayName: user.displayName
|
|
};
|
|
}
|
|
|
|
function createUser(id: string, displayName: string): User {
|
|
return {
|
|
id,
|
|
oderId: id,
|
|
username: displayName.toLowerCase(),
|
|
displayName,
|
|
status: 'online',
|
|
role: 'member',
|
|
joinedAt: 1
|
|
};
|
|
}
|