import { render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRoutesStub, useSearchParams } from "react-router"; import MainApp from "#/routes/root-layout"; import OptionService from "#/api/option-service/option-service.api"; import AuthService from "#/api/auth-service/auth-service.api"; import SettingsService from "#/api/settings-service/settings-service.api"; import { onboardingService } from "#/api/onboarding-service/onboarding-service.api"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; vi.mock("#/hooks/use-github-auth-url", () => ({ useGitHubAuthUrl: () => "https://github.com/oauth/authorize", })); vi.mock("#/hooks/use-is-on-tos-page", () => ({ useIsOnTosPage: () => false, })); vi.mock("#/hooks/use-auto-login", () => ({ useAutoLogin: () => {}, })); vi.mock("#/hooks/use-auth-callback", () => ({ useAuthCallback: () => {}, })); vi.mock("#/hooks/use-migrate-user-consent", () => ({ useMigrateUserConsent: () => ({ migrateUserConsent: vi.fn(), }), })); vi.mock("#/hooks/use-reo-tracking", () => ({ useReoTracking: () => {}, })); vi.mock("#/hooks/use-sync-posthog-consent", () => ({ useSyncPostHogConsent: () => {}, })); vi.mock("#/utils/custom-toast-handlers", () => ({ displaySuccessToast: vi.fn(), })); vi.mock("#/hooks/use-invitation", () => ({ useInvitation: () => ({ invitationToken: null, hasInvitation: false, buildOAuthStateData: (baseState: Record) => baseState, clearInvitation: vi.fn(), }), })); function LoginStub() { const [searchParams] = useSearchParams(); const emailVerificationRequired = searchParams.get("email_verification_required") === "true"; const emailVerified = searchParams.get("email_verified") === "true"; const emailVerificationText = "AUTH$PLEASE_CHECK_EMAIL_TO_VERIFY"; const returnTo = searchParams.get("returnTo"); return (
{emailVerified &&
} {emailVerificationRequired && (
{emailVerificationText}
)} {returnTo &&
{returnTo}
}
); } const RouterStub = createRoutesStub([ { Component: MainApp, path: "/", children: [ { Component: () =>
, path: "/", }, ], }, { Component: LoginStub, path: "/login", }, ]); const RouterStubWithLogin = createRoutesStub([ { Component: MainApp, path: "/", children: [ { Component: () =>
, path: "/", }, { Component: () =>
, path: "/settings", }, ], }, { Component: () =>
, path: "/login", }, ]); const RouterStubWithOnboarding = createRoutesStub([ { Component: MainApp, path: "/", children: [ { Component: () =>
, path: "/", }, ], }, { Component: () =>
, path: "/onboarding", }, ]); const RouterStubWithDeviceVerify = createRoutesStub([ { Component: MainApp, path: "/", children: [ { Component: () =>
, path: "/", }, { Component: () =>
, path: "/oauth/device/verify", }, ], }, { Component: LoginStub, path: "/login", }, ]); const renderMainApp = (initialEntries: string[] = ["/"]) => render(, { wrapper: ({ children }) => ( {children} ), }); const renderWithLoginStub = ( RouterStubComponent: ReturnType, initialEntries: string[] = ["/"], ) => render(, { wrapper: ({ children }) => ( {children} ), }); describe("MainApp", () => { beforeEach(() => { vi.clearAllMocks(); // @ts-expect-error - partial mock for testing vi.spyOn(OptionService, "getConfig").mockResolvedValue({ app_mode: "saas", posthog_client_key: "test-posthog-key", providers_configured: ["github"], auth_url: "https://auth.example.com", feature_flags: { enable_billing: false, hide_llm_settings: false, enable_jira: false, enable_jira_dc: false, enable_linear: false, hide_users_page: false, hide_billing_page: false, hide_integrations_page: false, enable_onboarding: true, }, }); vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); vi.spyOn(SettingsService, "getSettings").mockResolvedValue( MOCK_DEFAULT_USER_SETTINGS, ); vi.spyOn(onboardingService, "getStatus").mockResolvedValue({ should_complete_onboarding: false, }); vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), }); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("Email Verification", () => { it("should redirect to login when email_verification_required=true is in query params", async () => { const axiosError = { response: { status: 401 }, isAxiosError: true, }; vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError); renderMainApp(["/?email_verification_required=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should redirect to login when email_verified=true is in query params", async () => { const axiosError = { response: { status: 401 }, isAxiosError: true, }; vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError); renderMainApp(["/?email_verified=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should redirect to login when email_verification_required and email_verified params are in query params together", async () => { const axiosError = { response: { status: 401 }, isAxiosError: true, }; vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError); renderMainApp(["/?email_verification_required=true&email_verified=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should redirect to login when email_verification_required=true is in query params", async () => { const axiosError = { response: { status: 401 }, isAxiosError: true, }; vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError); renderMainApp(["/?email_verification_required=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should not display EmailVerificationModal when email_verification_required is not in query params", async () => { const axiosError = { response: { status: 401 }, isAxiosError: true, }; vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError); renderMainApp(["/"]); // User will be redirected to login, but modal should not show without query param await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); expect( screen.queryByTestId("email-verification-modal"), ).not.toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should not display email verified message when email_verified is not in query params", async () => { const axiosError = { response: { status: 401 }, isAxiosError: true, }; vi.spyOn(AuthService, "authenticate").mockRejectedValue(axiosError); renderMainApp(["/login"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); expect( screen.queryByTestId("email-verified-message"), ).not.toBeInTheDocument(); }, { timeout: 2000 }, ); }); }); describe("Unauthenticated redirect", () => { beforeEach(() => { vi.spyOn(AuthService, "authenticate").mockRejectedValue({ response: { status: 401 }, isAxiosError: true, }); }); it("should redirect unauthenticated SaaS users to /login", async () => { renderWithLoginStub(RouterStubWithLogin, ["/"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should redirect to /login with returnTo parameter when on a specific page", async () => { renderWithLoginStub(RouterStubWithLogin, ["/settings"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should preserve query parameters in returnTo when redirecting to login", async () => { renderWithLoginStub(RouterStubWithDeviceVerify, [ "/oauth/device/verify?user_code=F9XN6BKU", ]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); const returnToElement = screen.getByTestId("return-to-param"); expect(returnToElement).toBeInTheDocument(); expect(returnToElement.textContent).toBe( "/oauth/device/verify?user_code=F9XN6BKU", ); }, { timeout: 2000 }, ); }); }); describe("Re-authentication with stored login method", () => { it("should show ReauthModal instead of redirecting to /login when login method exists", async () => { // Arrange - user is unauthenticated but has a stored login method vi.spyOn(AuthService, "authenticate").mockRejectedValue({ response: { status: 401 }, isAxiosError: true, }); vi.stubGlobal("localStorage", { getItem: vi.fn((key: string) => { if (key === "wren_login_method") { return "github"; } return null; }), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), }); // Act renderWithLoginStub(RouterStubWithLogin, ["/"]); // Assert - should show ReauthModal (with "Logging back in" text), not redirect to /login await waitFor( () => { expect(screen.getByText("AUTH$LOGGING_BACK_IN")).toBeInTheDocument(); }, { timeout: 2000 }, ); // Login page should NOT be shown when login method exists expect(screen.queryByTestId("login-page")).not.toBeInTheDocument(); }); it("should redirect to /login when no login method is stored", async () => { // Arrange - user is unauthenticated and has no stored login method vi.spyOn(AuthService, "authenticate").mockRejectedValue({ response: { status: 401 }, isAxiosError: true, }); vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), }); // Act renderWithLoginStub(RouterStubWithLogin, ["/"]); // Assert - should redirect to /login await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); }); describe("Loading states", () => { it("should show loading spinner while config is loading without redirecting", async () => { // Arrange - config never resolves (loading state) vi.spyOn(OptionService, "getConfig").mockImplementation( () => new Promise(() => {}), ); vi.stubGlobal("localStorage", { getItem: vi.fn((key: string) => { if (key === "wren_login_method") { return "github"; } return null; }), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), }); // Act renderWithLoginStub(RouterStubWithLogin, ["/"]); // Assert - should show loading spinner await waitFor(() => { expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); }); // Should NOT redirect to login while loading expect(screen.queryByTestId("login-page")).not.toBeInTheDocument(); }); it("should show loading spinner while auth is loading without redirecting", async () => { // Arrange - auth never resolves (loading state) vi.spyOn(AuthService, "authenticate").mockImplementation( () => new Promise(() => {}), ); vi.stubGlobal("localStorage", { getItem: vi.fn((key: string) => { if (key === "wren_login_method") { return "github"; } return null; }), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), }); // Act renderWithLoginStub(RouterStubWithLogin, ["/"]); // Assert - should show loading spinner await waitFor(() => { expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); }); // Should NOT redirect to login while loading expect(screen.queryByTestId("login-page")).not.toBeInTheDocument(); }); }); describe("Invitation URL Parameters", () => { beforeEach(() => { vi.spyOn(AuthService, "authenticate").mockRejectedValue({ response: { status: 401 }, isAxiosError: true, }); }); it("should redirect to login when email_mismatch=true is in query params", async () => { renderMainApp(["/?email_mismatch=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should redirect to login when invitation_success=true is in query params", async () => { renderMainApp(["/?invitation_success=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should redirect to login when invitation_expired=true is in query params", async () => { renderMainApp(["/?invitation_expired=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should redirect to login when invitation_invalid=true is in query params", async () => { renderMainApp(["/?invitation_invalid=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); it("should redirect to login when already_member=true is in query params", async () => { renderMainApp(["/?already_member=true"]); await waitFor( () => { expect(screen.getByTestId("login-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); }); }); describe("Onboarding redirect", () => { it("should redirect authenticated SaaS users with incomplete onboarding to /onboarding", async () => { // Arrange: backend reports onboarding still required. vi.spyOn(onboardingService, "getStatus").mockResolvedValue({ should_complete_onboarding: true, }); // Act: render the home page. renderWithLoginStub(RouterStubWithOnboarding, ["/"]); // Assert: user lands on /onboarding instead of the home outlet. await waitFor( () => { expect(screen.getByTestId("onboarding-page")).toBeInTheDocument(); }, { timeout: 2000 }, ); expect(screen.queryByTestId("outlet-content")).not.toBeInTheDocument(); }); }); });