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 { 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('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('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('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; } interface ServiceContext { audio: { playLoop: ReturnType; stop: ReturnType; }; delivery: { sendCallEvent: ReturnType; }; directCallEvents: Subject; directMessages: { createConversation: ReturnType; createGroupConversation: ReturnType; openConversation: 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(options.currentUser, user)), createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({ ...createGroupConversation(conversationId, participants.map(participantToUser)), title })), openConversation: vi.fn(async () => undefined) }; const delivery = { directCallEvents$: directCallEvents.asObservable(), sendCallEvent: vi.fn(() => true) }; const audio = { playLoop: vi.fn(), stop: vi.fn() }; 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 injector = Injector.create({ providers: [ { provide: ChangeDetectionScheduler, useValue: { notify: vi.fn() } }, { provide: EffectScheduler, useValue: { add: vi.fn(), flush: vi.fn(), remove: vi.fn(), schedule: vi.fn() } }, { 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() } } ] }); return { audio, delivery, directCallEvents, directMessages, router, service: runInInjectionContext(injector, () => new DirectCallService()), voice, voiceSession }; } 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 }; }