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((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; stop: ReturnType; }; currentUser: ReturnType>; delivery: { sendCallEvent: ReturnType; }; directCallEvents: Subject; directMessages: { createConversation: ReturnType; createGroupConversation: ReturnType; openConversation: ReturnType; recordCallStarted: ReturnType; }; effectScheduler: { flush: ReturnType; }; router: { navigate: ReturnType; }; service: DirectCallService; voice: { broadcastMessage: ReturnType; disableVoice: ReturnType; stopVoiceHeartbeat: ReturnType; }; voiceSession: { endSession: ReturnType; }; } function createServiceContext(options: ServiceContextOptions): ServiceContext { const currentUser = signal(options.currentUser); const allUsers = signal(options.allUsers); const directCallEvents = new Subject(); 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, run: () => Promise ): Promise { 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 }; }