/* Copyright 2025 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { describe, it } from "vitest"; import { EventType, type IEvent, type IRoomTimelineData, MatrixEvent, type Room, } from "matrix-js-sdk"; import { withTestScheduler } from "../../utils/test"; import { aliceRtcMember, local, localRtcMember, } from "../../utils/test-fixtures"; import { type CallNotificationWrapper, createCallNotificationLifecycle$, type Props as CallNotificationLifecycleProps, } from "./CallNotificationLifecycle"; import { trackEpoch } from "../ObservableScope"; function mockRingEvent( eventId: string, lifetimeMs: number | undefined, sender = local.userId, ): CallNotificationWrapper { return { event_id: eventId, ...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }), notification_type: "ring", sender, } as unknown as CallNotificationWrapper; } describe("waitForCallPickup$", () => { it("unknown -> ringing -> timeout when notified and nobody joins", () => { withTestScheduler(({ scope, expectObservable, behavior, hot }) => { // No one ever joins (only local user) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a", { a: [] }).pipe(trackEpoch()), ), sentCallNotification$: hot("10ms a", { a: mockRingEvent("$notif1", 30), }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", { a: "unknown", b: "ringing", c: "timeout", }); }); }); it("ringing -> success if someone joins before timeout is reached", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a 19ms b", { a: [localRtcMember], b: [localRtcMember, aliceRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("5ms a", { a: mockRingEvent("$notif2", 100), }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 4ms b 14ms c", { a: "unknown", b: "ringing", c: "success", }); }); }); it("success when someone joins before we notify", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a 9ms b", { a: [localRtcMember], b: [localRtcMember, aliceRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("20ms a", { a: mockRingEvent("$notif2", 50), }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", { a: "unknown", b: "success", }); }); }); it("notify without lifetime -> immediate timeout", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a", { a: [localRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("10ms a", { a: mockRingEvent("$notif2", undefined), }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", { a: "unknown", b: "timeout", }); }); }); it("stays null when waitForCallPickup=false", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const validProps: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a--b", { a: [localRtcMember], b: [localRtcMember, aliceRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("10ms a", { a: mockRingEvent("$notif5", 30), }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const propsDeactivated = { ...validProps, options: { ...validProps.options, waitForCallPickup: false, }, }; const lifecycle = createCallNotificationLifecycle$(propsDeactivated); expectObservable(lifecycle.callPickupState$).toBe("n", { n: null, }); const lifecycleReference = createCallNotificationLifecycle$(validProps); expectObservable(lifecycleReference.callPickupState$).toBe("u--s", { u: "unknown", s: "success", }); }); }); it("decline before timeout window ends -> decline", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a", { a: [localRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("10ms a", { a: mockRingEvent("$decl1", 50), }), receivedDecline$: hot("40ms d", { d: [ new MatrixEvent({ type: EventType.RTCDecline, content: { "m.relates_to": { rel_type: "m.reference", event_id: "$decl1", }, }, }), {} as Room, undefined, false, {} as IRoomTimelineData, ], }), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms e", { a: "unknown", b: "ringing", e: "decline", }); }); }); it("decline after timeout window ends -> stays timeout", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a", { a: [localRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("10ms a", { a: mockRingEvent("$decl", 20), }), receivedDecline$: hot("40ms d", { d: [ new MatrixEvent({ type: EventType.RTCDecline, content: { "m.relates_to": { rel_type: "m.reference", event_id: "$decl", }, }, }), {} as Room, undefined, false, {} as IRoomTimelineData, ], }), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$, "50ms !").toBe( "a 9ms b 19ms e", { a: "unknown", b: "ringing", e: "timeout", }, ); }); }); // function testStaysRinging( declineEvent: Partial, expectDecline: boolean, ): void { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a", { a: [localRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("10ms a", { a: mockRingEvent("$right", 50), }), receivedDecline$: hot("20ms d", { d: [ new MatrixEvent(declineEvent), {} as Room, undefined, false, {} as IRoomTimelineData, ], }), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); const marbles = expectDecline ? "a 9ms b 9ms d" : "a 9ms b"; expectObservable(lifecycle.callPickupState$, "21ms !").toBe(marbles, { a: "unknown", b: "ringing", d: "decline", }); }); } const reference = (refId?: string, sender?: string): Partial => ({ event_id: "$decline", type: EventType.RTCDecline, sender: sender ?? "@other:example.org", content: { "m.relates_to": { rel_type: "m.reference", event_id: refId ?? "$right", }, }, }); it("decline reference works", () => { testStaysRinging(reference(), true); }); it("decline with wrong id is ignored (stays ringing)", () => { testStaysRinging(reference("$wrong"), false); }); it("decline with wrong id is ignored (stays ringing)", () => { testStaysRinging(reference(undefined, local.userId), false); }); });