diff --git a/apps/web/app/ClientEnvironmentRedirect.test.tsx b/apps/web/app/ClientEnvironmentRedirect.test.tsx new file mode 100644 index 0000000000..2f81f1ab3b --- /dev/null +++ b/apps/web/app/ClientEnvironmentRedirect.test.tsx @@ -0,0 +1,74 @@ +import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render } from "@testing-library/react"; +import { useRouter } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import ClientEnvironmentRedirect from "./ClientEnvironmentRedirect"; + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(), +})); + +describe("ClientEnvironmentRedirect", () => { + afterEach(() => { + cleanup(); + }); + + test("should redirect to the provided environment ID when no last environment exists", () => { + const mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + + // Mock localStorage + const localStorageMock = { + getItem: vi.fn().mockReturnValue(null), + }; + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + }); + + render(); + + expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id"); + }); + + test("should redirect to the last environment ID when it exists in localStorage", () => { + const mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + + // Mock localStorage with a last environment ID + const localStorageMock = { + getItem: vi.fn().mockReturnValue("last-env-id"), + }; + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + }); + + render(); + + expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS); + expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id"); + }); + + test("should update redirect when environment ID prop changes", () => { + const mockPush = vi.fn(); + vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any); + + // Mock localStorage + const localStorageMock = { + getItem: vi.fn().mockReturnValue(null), + }; + Object.defineProperty(window, "localStorage", { + value: localStorageMock, + }); + + const { rerender } = render(); + expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id"); + + // Clear mock calls + mockPush.mockClear(); + + // Rerender with new environment ID + rerender(); + expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id"); + }); +}); diff --git a/apps/web/app/layout.test.tsx b/apps/web/app/layout.test.tsx index 40527c1cd4..1cad3293ea 100644 --- a/apps/web/app/layout.test.tsx +++ b/apps/web/app/layout.test.tsx @@ -1,10 +1,11 @@ import { getLocale } from "@/tolgee/language"; import { getTolgee } from "@/tolgee/server"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup } from "@testing-library/react"; import { TolgeeInstance } from "@tolgee/react"; import React from "react"; +import { renderToString } from "react-dom/server"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import RootLayout from "./layout"; +import RootLayout, { metadata } from "./layout"; // Mock dependencies for the layout @@ -40,15 +41,6 @@ vi.mock("@/tolgee/server", () => ({ getTolgee: vi.fn(), })); -vi.mock("@/modules/ui/components/post-hog-client", () => ({ - PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => ( -
- PHProvider: {posthogEnabled} - {children} -
- ), -})); - vi.mock("@/tolgee/client", () => ({ TolgeeNextProvider: ({ children, @@ -95,10 +87,53 @@ describe("RootLayout", () => { const children =
Child Content
; const element = await RootLayout({ children }); - render(element); + const html = renderToString(element); - expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument(); - expect(screen.getByTestId("sentry-provider")).toBeInTheDocument(); - expect(screen.getByTestId("child")).toHaveTextContent("Child Content"); + // Create a container and set its innerHTML + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + + // Now we can use screen queries on the rendered content + expect(container.querySelector('[data-testid="tolgee-next-provider"]')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="sentry-provider"]')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="child"]')).toHaveTextContent("Child Content"); + + // Cleanup + document.body.removeChild(container); + }); + + test("renders with different locale", async () => { + const fakeLocale = "de-DE"; + vi.mocked(getLocale).mockResolvedValue(fakeLocale); + + const fakeStaticData = { key: "value" }; + const fakeTolgee = { + loadRequired: vi.fn().mockResolvedValue(fakeStaticData), + }; + vi.mocked(getTolgee).mockResolvedValue(fakeTolgee as unknown as TolgeeInstance); + + const children =
Child Content
; + const element = await RootLayout({ children }); + const html = renderToString(element); + + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + + const tolgeeProvider = container.querySelector('[data-testid="tolgee-next-provider"]'); + expect(tolgeeProvider).toHaveTextContent(fakeLocale); + + document.body.removeChild(container); + }); + + test("exports correct metadata", () => { + expect(metadata).toEqual({ + title: { + template: "%s | Formbricks", + default: "Formbricks", + }, + description: "Open-Source Survey Suite", + }); }); }); diff --git a/apps/web/app/not-found.test.tsx b/apps/web/app/not-found.test.tsx new file mode 100644 index 0000000000..ece5afabef --- /dev/null +++ b/apps/web/app/not-found.test.tsx @@ -0,0 +1,37 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/preact"; +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import NotFound from "./not-found"; + +describe("NotFound", () => { + afterEach(() => { + cleanup(); + }); + + test("renders 404 page with correct content", () => { + render(); + + // Check for the 404 text + const errorCode = screen.getByTestId("error-code"); + expect(errorCode).toBeInTheDocument(); + expect(errorCode).toHaveClass("text-sm", "font-semibold"); + expect(errorCode).toHaveTextContent("404"); + + // Check for the heading + const heading = screen.getByRole("heading", { name: "Page not found" }); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveClass("mt-2", "text-2xl", "font-bold"); + + // Check for the error message + const errorMessage = screen.getByTestId("error-message"); + expect(errorMessage).toBeInTheDocument(); + expect(errorMessage).toHaveClass("mt-2", "text-base"); + expect(errorMessage).toHaveTextContent("Sorry, we couldn't find the page you're looking for."); + + // Check for the button + const button = screen.getByRole("button", { name: "Back to home" }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass("mt-8"); + }); +}); diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index c3e38a0ef7..a48fa6a5d0 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -3,18 +3,18 @@ import Link from "next/link"; const NotFound = () => { return ( - <> -
-

404

-

Page not found

-

- Sorry, we couldn’t find the page you’re looking for. -

- - - -
- +
+

+ 404 +

+

Page not found

+

+ Sorry, we couldn't find the page you're looking for. +

+ + + +
); }; diff --git a/apps/web/app/page.test.tsx b/apps/web/app/page.test.tsx new file mode 100644 index 0000000000..a75ad3e36c --- /dev/null +++ b/apps/web/app/page.test.tsx @@ -0,0 +1,391 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TMembership } from "@formbricks/types/memberships"; +import { TOrganization } from "@formbricks/types/organizations"; +import { TUser } from "@formbricks/types/user"; +import Page from "./page"; + +// Mock dependencies +vi.mock("@/lib/environment/service", () => ({ + getFirstEnvironmentIdByUserId: vi.fn(), +})); + +vi.mock("@/lib/instance/service", () => ({ + getIsFreshInstance: vi.fn(), +})); + +vi.mock("@/lib/membership/service", () => ({ + getMembershipByUserIdOrganizationId: vi.fn(), +})); + +vi.mock("@/lib/membership/utils", () => ({ + getAccessFlags: vi.fn(), +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganizationsByUserId: vi.fn(), +})); + +vi.mock("@/lib/user/service", () => ({ + getUser: vi.fn(), +})); + +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/modules/ui/components/client-logout", () => ({ + ClientLogout: () =>
Client Logout
, +})); + +vi.mock("@/app/ClientEnvironmentRedirect", () => ({ + default: ({ environmentId }: { environmentId: string }) => ( +
Environment ID: {environmentId}
+ ), +})); + +describe("Page", () => { + beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("redirects to setup/intro when no session and fresh instance", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { redirect } = await import("next/navigation"); + + vi.mocked(getServerSession).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(true); + + await Page(); + + expect(redirect).toHaveBeenCalledWith("/setup/intro"); + }); + + test("redirects to auth/login when no session and not fresh instance", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { redirect } = await import("next/navigation"); + + vi.mocked(getServerSession).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + + await Page(); + + expect(redirect).toHaveBeenCalledWith("/auth/login"); + }); + + test("shows client logout when user is not found", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { render } = await import("@testing-library/react"); + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(null); + + const result = await Page(); + const { container } = render(result); + + expect(container.querySelector('[data-testid="client-logout"]')).toBeInTheDocument(); + }); + + test("redirects to organization creation when user has no organizations", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { getOrganizationsByUserId } = await import("@/lib/organization/service"); + const { redirect } = await import("next/navigation"); + + const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: null, + objective: null, + notificationSettings: { + alert: {}, + weeklySummary: {}, + unsubscribedOrganizationIds: [], + }, + locale: "en-US", + lastLoginAt: null, + isActive: true, + }; + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([]); + + await Page(); + + expect(redirect).toHaveBeenCalledWith("/setup/organization/create"); + }); + + test("redirects to project creation when user has organizations but no environment", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { getOrganizationsByUserId } = await import("@/lib/organization/service"); + const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service"); + const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); + const { getAccessFlags } = await import("@/lib/membership/utils"); + const { redirect } = await import("next/navigation"); + + const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: null, + objective: null, + notificationSettings: { + alert: {}, + weeklySummary: {}, + unsubscribedOrganizationIds: [], + }, + locale: "en-US", + lastLoginAt: null, + isActive: true, + }; + + const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }; + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "owner", + }; + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); + vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ + isManager: false, + isOwner: true, + isBilling: false, + isMember: true, + }); + + await Page(); + + expect(redirect).toHaveBeenCalledWith(`/organizations/${mockOrganization.id}/projects/new/mode`); + }); + + test("redirects to landing when user has organizations but no environment and is not owner/manager", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { getOrganizationsByUserId } = await import("@/lib/organization/service"); + const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service"); + const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); + const { getAccessFlags } = await import("@/lib/membership/utils"); + const { redirect } = await import("next/navigation"); + + const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: null, + objective: null, + notificationSettings: { + alert: {}, + weeklySummary: {}, + unsubscribedOrganizationIds: [], + }, + locale: "en-US", + lastLoginAt: null, + isActive: true, + }; + + const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }; + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "member", + }; + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); + vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ + isManager: false, + isOwner: false, + isBilling: false, + isMember: true, + }); + + await Page(); + + expect(redirect).toHaveBeenCalledWith(`/organizations/${mockOrganization.id}/landing`); + }); + + test("renders ClientEnvironmentRedirect when user has environment", async () => { + const { getServerSession } = await import("next-auth"); + const { getIsFreshInstance } = await import("@/lib/instance/service"); + const { getUser } = await import("@/lib/user/service"); + const { getOrganizationsByUserId } = await import("@/lib/organization/service"); + const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service"); + const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service"); + const { getAccessFlags } = await import("@/lib/membership/utils"); + const { render } = await import("@testing-library/react"); + + const mockUser: TUser = { + id: "test-user-id", + name: "Test User", + email: "test@example.com", + emailVerified: null, + imageUrl: null, + twoFactorEnabled: false, + identityProvider: "email", + createdAt: new Date(), + updatedAt: new Date(), + role: null, + objective: null, + notificationSettings: { + alert: {}, + weeklySummary: {}, + unsubscribedOrganizationIds: [], + }, + locale: "en-US", + lastLoginAt: null, + isActive: true, + }; + + const mockOrganization: TOrganization = { + id: "test-org-id", + name: "Test Organization", + createdAt: new Date(), + updatedAt: new Date(), + billing: { + stripeCustomerId: null, + plan: "free", + period: "monthly", + limits: { + projects: 3, + monthly: { + responses: 1500, + miu: 2000, + }, + }, + periodStart: new Date(), + }, + isAIEnabled: false, + }; + + const mockMembership: TMembership = { + organizationId: "test-org-id", + userId: "test-user-id", + accepted: true, + role: "member", + }; + + const mockEnvironmentId = "test-env-id"; + + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "test-user-id" }, + } as any); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getUser).mockResolvedValue(mockUser); + vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]); + vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(mockEnvironmentId); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(getAccessFlags).mockReturnValue({ + isManager: false, + isOwner: false, + isBilling: false, + isMember: true, + }); + + const result = await Page(); + const { container } = render(result); + + expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent( + `Environment ID: ${mockEnvironmentId}` + ); + }); +}); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index a305150a17..e062110338 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -17,9 +17,9 @@ const Page = async () => { if (!session) { if (isFreshInstance) { - redirect("/setup/intro"); + return redirect("/setup/intro"); } else { - redirect("/auth/login"); + return redirect("/auth/login"); } }