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");
}
}