import 'dotenv/config'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { StreamVideoClient } from '../StreamVideoClient'; import { StreamClient } from '@stream-io/node-sdk'; import { StreamClientOptions, StreamVideoEvent, } from '../coordinator/connection/types'; import { CallingState } from '../store'; import { settled } from '../helpers/concurrency'; import { getCallInitConcurrencyTag } from '../helpers/clientUtils'; import { CallCreatedPayload, CallRingPayload } from './data'; import { expectCall, expectEvent } from './clientTestUtils'; const apiKey = process.env.STREAM_API_KEY!; const secret = process.env.STREAM_SECRET!; describe('StreamVideoClient Ringing', () => { const serverClient = new StreamClient(apiKey, secret); const testUserIds = ['oliver', 'sacha', 'marcelo']; let oliverClient: StreamVideoClient; let sachaClient: StreamVideoClient; let marceloClient: StreamVideoClient; const createClients = async ( userIds: string[], clientOptions?: StreamClientOptions, ) => { const makeClient = async (userId: string) => { const client = new StreamVideoClient(apiKey, clientOptions); const token = serverClient.generateUserToken({ user_id: userId }); await client.connectUser({ id: userId }, token); return client; }; return await Promise.all(userIds.map(makeClient)); }; const disconnectClients = async () => { await Promise.all([ oliverClient.disconnectUser(), sachaClient.disconnectUser(), marceloClient.disconnectUser(), ]); }; beforeEach(async () => { [oliverClient, sachaClient, marceloClient] = await createClients(testUserIds); }); afterEach(async () => { await disconnectClients(); }); describe('standard ringing', async () => { // TODO OL: enable this test once we know more: // https://getstream.slack.com/archives/C040262MY9K/p1755178272202659 it.skip('server-side: oliver should ring all members', async () => { const oliverRing = expectEvent(oliverClient, 'call.ring'); const sachaRing = expectEvent(sachaClient, 'call.ring'); const marceloRing = expectEvent(marceloClient, 'call.ring'); const call = serverClient.video.call('default', crypto.randomUUID()); await call.create({ ring: true, data: { created_by_id: 'oliver', members: [ { user_id: 'oliver' }, { user_id: 'sacha' }, { user_id: 'marcelo' }, ], }, }); const [oliverRingEvent, sachaRingEvent, marceloRingEvent] = await Promise.all([oliverRing, sachaRing, marceloRing]); expect(oliverRingEvent.call.cid).toBe(call.cid); expect(sachaRingEvent.call.cid).toBe(call.cid); expect(marceloRingEvent.call.cid).toBe(call.cid); const oliverCall = await expectCall(oliverClient, call.cid); const sachaCall = await expectCall(sachaClient, call.cid); const marceloCall = await expectCall(marceloClient, call.cid); expect(oliverCall).toBeDefined(); expect(sachaCall).toBeDefined(); expect(marceloCall).toBeDefined(); expect(oliverCall.ringing).toBe(true); expect(sachaCall.ringing).toBe(true); expect(marceloCall.ringing).toBe(true); }); it('client-side: oliver should ring all members', async () => { const oliverRing = expectEvent(oliverClient, 'call.ring'); const sachaRing = expectEvent(sachaClient, 'call.ring'); const marceloRing = expectEvent(marceloClient, 'call.ring'); const call = oliverClient.call('default', crypto.randomUUID()); await call.create({ ring: true, data: { members: [ { user_id: 'oliver' }, { user_id: 'sacha' }, { user_id: 'marcelo' }, ], }, }); expect(call.ringing).toBe(true); const ringEventsPromise = Promise.all([sachaRing, marceloRing]); await expect(ringEventsPromise).resolves.toHaveLength(2); await expect(oliverRing).rejects.toThrow(); // caller doesn't get ring event const [sachaRingEvent, marceloRingEvent] = await ringEventsPromise; expect(sachaRingEvent.call.cid).toBe(call.cid); expect(marceloRingEvent.call.cid).toBe(call.cid); const sachaCall = await expectCall(sachaClient, call.cid); const marceloCall = await expectCall(marceloClient, call.cid); expect(sachaCall).toBeDefined(); expect(marceloCall).toBeDefined(); expect(sachaCall.ringing).toBe(true); expect(marceloCall.ringing).toBe(true); }); }); describe('ringing individual members', () => { it('should ring individual members', async () => { const oliverCall = oliverClient.call('default', crypto.randomUUID()); await oliverCall.create({ ring: false, // don't ring all members by default data: { members: [ { user_id: 'oliver' }, { user_id: 'sacha' }, { user_id: 'marcelo' }, ], }, }); // no one should get a ring event yet const oliverRing = expectEvent(oliverClient, 'call.ring', 500); const sachaRing = expectEvent(sachaClient, 'call.ring', 500); const marceloRing = expectEvent(marceloClient, 'call.ring', 500); await expect( Promise.all([oliverRing, sachaRing, marceloRing]), ).rejects.toThrow(); // oliver is calling sacha. only sacha should get a ring event const sachaIndividualRing = expectEvent(sachaClient, 'call.ring'); const marceloIndividualRing = expectEvent(marceloClient, 'call.ring'); await oliverCall.ring({ members_ids: ['sacha'] }); await expect(sachaIndividualRing).resolves.toHaveProperty( 'call.cid', oliverCall.cid, ); await expect(marceloIndividualRing).rejects.toThrow(); const sachaCall = await expectCall(sachaClient, oliverCall.cid); expect(sachaCall).toBeDefined(); // sacha is calling marcelo. only marcelo should get a ring event const oliverIndividualRing = expectEvent(oliverClient, 'call.ring'); const marceloIndividualRing2 = expectEvent(marceloClient, 'call.ring'); await sachaCall.ring({ members_ids: ['marcelo'] }); await expect(marceloIndividualRing2).resolves.toHaveProperty( 'call.cid', sachaCall.cid, ); await expect(oliverIndividualRing).rejects.toThrow(); const marceloCall = await expectCall(marceloClient, sachaCall.cid); expect(marceloCall).toBeDefined(); }); }); describe('ringing concurrently', async () => { it('dispatches `call.ring` before `call.created`', async () => { oliverClient.streamClient.dispatchEvent( CallRingPayload as StreamVideoEvent, ); oliverClient.streamClient.dispatchEvent( CallCreatedPayload as StreamVideoEvent, ); await settled(getCallInitConcurrencyTag(CallCreatedPayload.call_cid)); expect(oliverClient.state.calls.length).toBe(1); expect(oliverClient.state.calls[0].state.callingState).toBe( CallingState.RINGING, ); }); it('dispatches `call.created` then `call.ring`', async () => { oliverClient.streamClient.dispatchEvent( CallCreatedPayload as StreamVideoEvent, ); oliverClient.streamClient.dispatchEvent( CallRingPayload as StreamVideoEvent, ); await settled(getCallInitConcurrencyTag(CallCreatedPayload.call_cid)); expect(oliverClient.state.calls.length).toBe(1); expect(oliverClient.state.calls[0].state.callingState).toBe( CallingState.RINGING, ); }); it('receives a push notification followed by `call.ring` then `call.created`', async () => { const call = await oliverClient.onRingingCall(CallRingPayload.call_cid); oliverClient.streamClient.dispatchEvent( CallRingPayload as StreamVideoEvent, ); oliverClient.streamClient.dispatchEvent( CallCreatedPayload as StreamVideoEvent, ); await settled(getCallInitConcurrencyTag(CallRingPayload.call_cid)); expect(oliverClient.state.calls.length).toBe(1); expect(oliverClient.state.calls[0]).toBe(call); expect(call.ringing).toBe(true); }); it('new call instance with doJoinRequest({ ring: true }) should replace old instance in store', async () => { const callId = crypto.randomUUID(); const call = oliverClient.call('default', callId, { reuseInstance: true, }); const callCreatedEvent = expectEvent(oliverClient, 'call.created'); const serverCall = serverClient.video.call('default', callId); await serverCall.create({ data: { created_by_id: 'oliver', members: [{ user_id: 'oliver' }, { user_id: 'sacha' }], }, }); // the event arrives and since the store is empty registers a new IDLE call await callCreatedEvent; // join registers or updates the call await call.doJoinRequest({ ring: true }); expect(oliverClient.state.calls.length).toBe(1); expect(oliverClient.state.calls[0]).toBe(call); expect(oliverClient.state.calls[0].ringing).toBe(true); }); }); describe('ringing interruption', () => { const setupInitialCall = async () => { const sachaRing = expectEvent(sachaClient, 'call.ring'); const oliverCall = oliverClient.call('default', crypto.randomUUID()); await oliverCall.create({ ring: true, data: { members: [{ user_id: 'oliver' }, { user_id: 'sacha' }], }, }); await expect(sachaRing).resolves.toHaveProperty( 'call.cid', oliverCall.cid, ); const sachaCall = await expectCall(sachaClient, oliverCall.cid); expect(sachaCall).toBeDefined(); expect(oliverCall.state.callingState).toBe(CallingState.RINGING); expect(sachaCall.state.callingState).toBe(CallingState.RINGING); return { oliverCall, sachaCall }; }; it('should reject the call when the caller is busy', async () => { [oliverClient, sachaClient, marceloClient] = await createClients( testUserIds, { rejectCallWhenBusy: true, }, ); //initiate a call between oliver and sacha await setupInitialCall(); //marcelo is calling oliver (the caller). call should be automatically rejected const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected'); const marceloCall = marceloClient.call('default', crypto.randomUUID()); await marceloCall.create({ ring: true, data: { members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }], }, }); await expect(marceloIndividualRing).resolves.toHaveProperty( 'call.cid', marceloCall.cid, ); expect(oliverClient.state.calls.length).toBe(1); await disconnectClients(); }); it('should reject the call when the callee is busy', async () => { [oliverClient, sachaClient, marceloClient] = await createClients( testUserIds, { rejectCallWhenBusy: true, }, ); //initiate a call between oliver and sacha await setupInitialCall(); //marcelo is calling sacha (the callee). call should be automatically rejected const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected'); const marceloCall = marceloClient.call('default', crypto.randomUUID()); await marceloCall.create({ ring: true, data: { members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }], }, }); await expect(marceloIndividualRing).resolves.toHaveProperty( 'call.cid', marceloCall.cid, ); expect(sachaClient.state.calls.length).toBe(1); await disconnectClients(); }); it('should allow multiple simultaneous calls to the same caller when rejectCallWhenBusy is false', async () => { //initiate a call between oliver and sacha await setupInitialCall(); //marcelo is calling oliver (the caller) const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected'); const marceloCall = marceloClient.call('default', crypto.randomUUID()); await marceloCall.create({ ring: true, data: { members: [{ user_id: 'marcelo' }, { user_id: 'oliver' }], }, }); //call should not be automatically rejected, exception will be thrown by timeout await expect(marceloIndividualRing).rejects.toThrow(); //the caller has 2 calls available expect(oliverClient.state.calls.length).toBe(2); }); it('should allow multiple simultaneous calls to the same callee when rejectCallWhenBusy is false', async () => { //initiate a call between oliver and sacha await setupInitialCall(); //marcelo is calling sacha (the callee) const marceloIndividualRing = expectEvent(marceloClient, 'call.rejected'); const marceloCall = marceloClient.call('default', crypto.randomUUID()); await marceloCall.create({ ring: true, data: { members: [{ user_id: 'marcelo' }, { user_id: 'sacha' }], }, }); //call should not be automatically rejected, exception will be thrown by timeout await expect(marceloIndividualRing).rejects.toThrow(); //the callee has 2 calls available expect(sachaClient.state.calls.length).toBe(2); }); }); });