Files
Toju/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts

599 lines
20 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 {
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<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
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>;
};
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(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
};
}