Files
Toju/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts
T
2026-07-14 00:41:05 +02:00

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
};
}