Fix private calls
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
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));
|
||||
|
||||
context.directCallEvents.next(createCallEvent('leave', alice, ['alice', 'bob']));
|
||||
|
||||
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
|
||||
expect(context.audio.stop).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('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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user