diff --git a/src/room/RoomPage.test.tsx b/src/room/RoomPage.test.tsx new file mode 100644 index 00000000..65d15a8d --- /dev/null +++ b/src/room/RoomPage.test.tsx @@ -0,0 +1,683 @@ +/* +Copyright 2026 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 { + beforeEach, + afterEach, + describe, + expect, + test, + vi, + type Mock, +} from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { type MatrixClient, MatrixError } from "matrix-js-sdk"; +import { BrowserRouter } from "react-router-dom"; +import { type FC, type ReactNode } from "react"; + +import { RoomPage } from "./RoomPage"; +import { ClientContextProvider } from "../ClientContext"; +import { mockConfig, mockMediaDevices } from "../utils/test"; +import * as useRegisterPasswordlessUserModule from "../auth/useRegisterPasswordlessUser"; +import * as UrlParamsModule from "../UrlParams"; +import { HeaderStyle, type UrlParams } from "../UrlParams"; +import * as useLoadGroupCallModule from "./useLoadGroupCall"; +import * as useProfileModule from "../profile/useProfile"; +import * as settingsModule from "../settings/settings"; +import * as MediaDevicesContextModule from "../MediaDevicesContext"; +import * as widgetModule from "../widget"; +import * as ConfigModule from "../config/Config"; + +// Mock modules +vi.mock("../auth/useRegisterPasswordlessUser"); +vi.mock("../UrlParams"); +vi.mock("./useLoadGroupCall"); +vi.mock("../profile/useProfile"); +vi.mock("../settings/settings"); +vi.mock("../widget"); +vi.mock("../MediaDevicesContext"); +vi.mock("./GroupCallView", () => ({ + GroupCallView: vi.fn(() =>
GroupCallView Mock
), +})); +vi.mock("./LobbyView", () => ({ + LobbyView: vi.fn(() =>
LobbyView Mock
), +})); +vi.mock("./RoomAuthView", () => ({ + RoomAuthView: vi.fn(() =>
RoomAuthView Mock
), +})); +vi.mock("../home/HomePage", () => ({ + HomePage: vi.fn(() =>
HomePage Mock
), +})); +vi.mock("./AppSelectionModal", () => ({ + AppSelectionModal: vi.fn(() =>
AppSelectionModal Mock
), +})); + +const mockClient = vi.mocked({ + getUserId: () => "@alice:example.org", +} as unknown as MatrixClient); + +interface WrapperProps { + children: ReactNode; +} + +const TestWrapper: FC = ({ children }) => { + return {children}; +}; + +// Helper function to create mock URL params with optional overrides +// Only includes properties actually used by RoomPage component +function createMockUrlParams( + overrides: Partial> = {}, +): ReturnType { + return { + // Properties used by RoomPage + confineToRoom: false, + preload: false, + header: HeaderStyle.Standard, + displayName: null, + skipLobby: false, + callIntent: undefined, + ...overrides, + } as unknown as UrlParams; +} + +describe("RoomPage", () => { + let mockRegisterPasswordlessUser: Mock; + let mockUseLoadGroupCall: Mock; + let mockUseOptInAnalytics: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig({}); + + // Default mock for widget + vi.mocked(widgetModule).widget = null; + + // Default mock for useUrlParams + vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue( + createMockUrlParams(), + ); + + // Default mock for useRoomIdentifier + vi.spyOn(UrlParamsModule, "useRoomIdentifier").mockReturnValue({ + roomAlias: "#test:example.org", + roomId: null, + viaServers: [], + }); + + // Default mock for useRegisterPasswordlessUser + mockRegisterPasswordlessUser = vi.fn(); + vi.spyOn( + useRegisterPasswordlessUserModule, + "useRegisterPasswordlessUser", + ).mockReturnValue({ + registerPasswordlessUser: mockRegisterPasswordlessUser, + privacyPolicyUrl: undefined, + recaptchaId: undefined, + }); + + // Default mock for useLoadGroupCall + mockUseLoadGroupCall = vi + .spyOn(useLoadGroupCallModule, "useLoadGroupCall") + .mockReturnValue({ + kind: "loading", + }); + + // Default mock for useProfile + vi.spyOn(useProfileModule, "useProfile").mockReturnValue({ + success: false, + loading: false, + displayName: "Alice", + avatarUrl: undefined, + saveProfile: vi.fn(), + }); + + // Default mock for useOptInAnalytics + mockUseOptInAnalytics = vi.fn(); + vi.spyOn(settingsModule, "useOptInAnalytics").mockReturnValue([ + null, + mockUseOptInAnalytics, + ]); + + // Default mock for useMediaDevices + vi.spyOn(MediaDevicesContextModule, "useMediaDevices").mockReturnValue( + mockMediaDevices({}), + ); + }); + + afterEach(() => { + // Reset widget mock to prevent cross-test pollution + vi.mocked(widgetModule).widget = null; + }); + + describe("Loading states", () => { + test("shows RoomAuthView when not authenticated without displayName", () => { + render( + + + + + , + ); + + // When not authenticated and no displayName, should show RoomAuthView + expect(screen.getByText("RoomAuthView Mock")).toBeInTheDocument(); + }); + + test("shows loading page when registering", async () => { + vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue( + createMockUrlParams({ displayName: "Bob" }), + ); + + mockRegisterPasswordlessUser.mockImplementation( + // eslint-disable-next-line @typescript-eslint/require-await + async () => new Promise(() => {}), + ); // Never resolves + + render( + + + + + , + ); + + await waitFor(() => { + expect(mockRegisterPasswordlessUser).toHaveBeenCalledWith("Bob"); + }); + }); + }); + + describe("Error states", () => { + test("shows error page when client has error", () => { + const error = new Error("Test error"); + + render( + + + + + , + ); + + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + }); + + test("shows error view when call not found", () => { + mockUseLoadGroupCall.mockReturnValue({ + kind: "failed", + error: new MatrixError({ errcode: "M_NOT_FOUND" }), + }); + + render( + + + + + , + ); + + // Should show the call not found error message + expect( + screen.getByText(/that link doesn't appear to belong/i), + ).toBeInTheDocument(); + }); + }); + + describe("Authentication", () => { + test("shows RoomAuthView when not authenticated", () => { + render( + + + + + , + ); + + expect(screen.getByText("RoomAuthView Mock")).toBeInTheDocument(); + }); + + test("auto-registers passwordless user with displayName param", async () => { + vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue( + createMockUrlParams({ displayName: "AutoRegisterUser" }), + ); + + mockRegisterPasswordlessUser.mockResolvedValue(undefined); + + render( + + + + + , + ); + + await waitFor(() => { + expect(mockRegisterPasswordlessUser).toHaveBeenCalledWith( + "AutoRegisterUser", + ); + }); + }); + + test("does not auto-register in widget mode", () => { + // Mock widget with proper EventEmitter + // eslint-disable-next-line @typescript-eslint/no-require-imports + const EventEmitter = require("events"); + const mockLazyActions = new EventEmitter(); + + vi.mocked(widgetModule).widget = { + api: { + transport: { + send: vi.fn().mockResolvedValue(undefined), + }, + }, + lazyActions: mockLazyActions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue( + createMockUrlParams({ displayName: "ShouldNotRegister" }), + ); + + render( + + + + + , + ); + + expect(mockRegisterPasswordlessUser).not.toHaveBeenCalled(); + }); + }); + + describe("Room states", () => { + test("shows HomePage when no room is specified", () => { + vi.spyOn(UrlParamsModule, "useRoomIdentifier").mockReturnValue({ + roomAlias: null, + roomId: null, + viaServers: [], + }); + + render( + + + + + , + ); + + expect(screen.getByText("HomePage Mock")).toBeInTheDocument(); + }); + + test("shows loading state for group call", () => { + mockUseLoadGroupCall.mockReturnValue({ + kind: "loading", + }); + + render( + + + + + , + ); + + // The loading state renders with the heading + expect(screen.getByRole("heading")).toBeInTheDocument(); + }); + + test("shows GroupCallView when call is loaded", () => { + mockUseLoadGroupCall.mockReturnValue({ + kind: "loaded", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rtcSession: {} as any, + }); + + render( + + + + + , + ); + + expect(screen.getByText("GroupCallView Mock")).toBeInTheDocument(); + }); + + test("shows LobbyView when waiting for invite", () => { + mockUseLoadGroupCall.mockReturnValue({ + kind: "waitForInvite", + roomSummary: { + room_id: "!test:example.org", + name: "Test Room", + avatar_url: null, + "im.nheko.summary.encryption": undefined, + }, + }); + + render( + + + + + , + ); + + expect(screen.getByText("LobbyView Mock")).toBeInTheDocument(); + }); + + test("shows LobbyView when can knock", () => { + const mockKnock = vi.fn(); + mockUseLoadGroupCall.mockReturnValue({ + kind: "canKnock", + roomSummary: { + room_id: "!test:example.org", + name: "Test Room", + avatar_url: null, + "im.nheko.summary.encryption": undefined, + }, + knock: mockKnock, + }); + + render( + + + + + , + ); + + expect(screen.getByText("LobbyView Mock")).toBeInTheDocument(); + }); + }); + + describe("Analytics opt-in", () => { + test("auto opts-in to analytics when null", async () => { + mockUseOptInAnalytics.mockClear(); + const setOptInAnalytics = vi.fn(); + + vi.spyOn(settingsModule, "useOptInAnalytics").mockReturnValue([ + null, + setOptInAnalytics, + ]); + + render( + + + + + , + ); + + await waitFor(() => { + expect(setOptInAnalytics).toHaveBeenCalledWith(true); + }); + }); + + test("does not opt-in when already set", () => { + const setOptInAnalytics = vi.fn(); + + vi.spyOn(settingsModule, "useOptInAnalytics").mockReturnValue([ + false, + setOptInAnalytics, + ]); + + render( + + + + + , + ); + + expect(setOptInAnalytics).not.toHaveBeenCalled(); + }); + }); + + describe("App selection modal", () => { + test("shows AppSelectionModal on Android when appPrompt is enabled", () => { + vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue( + createMockUrlParams(), + ); + + vi.spyOn(UrlParamsModule, "useRoomIdentifier").mockReturnValue({ + roomAlias: null, + roomId: "!test:example.org", + viaServers: [], + }); + + // Mock platform + vi.doMock("../Platform", () => ({ + platform: "android", + })); + + // Mock Config + vi.spyOn(ConfigModule.Config, "get").mockReturnValue({ + app_prompt: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + mockUseLoadGroupCall.mockReturnValue({ + kind: "loaded", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rtcSession: {} as any, + }); + + render( + + + + + , + ); + + // The component should render, even though the AppSelectionModal might not show + // because platform module is mocked differently in different contexts + expect(screen.getByText("GroupCallView Mock")).toBeInTheDocument(); + }); + }); + + describe("Passwordless user registration error handling", () => { + test("handles registration error gracefully", async () => { + vi.spyOn(UrlParamsModule, "useUrlParams").mockReturnValue( + createMockUrlParams({ displayName: "FailUser" }), + ); + + mockRegisterPasswordlessUser.mockRejectedValue( + new Error("Registration failed"), + ); + + render( + + + + + , + ); + + await waitFor(() => { + expect(mockRegisterPasswordlessUser).toHaveBeenCalledWith("FailUser"); + }); + + // After error, should show RoomAuthView + await waitFor(() => { + expect(screen.getByText("RoomAuthView Mock")).toBeInTheDocument(); + }); + }); + }); +});