Compare commits

...

12 Commits

Author SHA1 Message Date
Matti Nannt
c270688e8f chore: update remaining npm dependencies (#5685) 2025-05-07 01:15:01 +02:00
victorvhs017
00c86c7082 chore: add tests to environments path - part 3 (#5680) 2025-05-07 00:37:36 +02:00
Matti Nannt
e95e9f9fda fix: security issue because of outdated pnpm version (#5683) 2025-05-07 00:17:54 +02:00
Matti Nannt
1588c2f47b chore: remove config and script files from test coverage (#5684) 2025-05-06 22:21:45 +02:00
Vijay
53850c96db fix: sonar security hotspots (https, --ignore-scripts, api_key, math.random) (#5538)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-06 20:41:35 +02:00
Vijay
ae2cb15055 fix: sonar security hotspot (permission issue - non-root user in Dockerfile) (#5411)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-06 19:14:51 +02:00
Matti Nannt
8bf1e096c0 chore: move dependencies to devDependencies if possible (#5679) 2025-05-06 18:57:51 +02:00
Anshuman Pandey
0052dc88f0 fix: increases language button size (#5677) 2025-05-06 16:07:26 +00:00
Matti Nannt
d67d62df45 chore: update zod dependency, remove unused labeler action (#5678) 2025-05-06 18:18:27 +02:00
Piyush Gupta
5d45de6bc4 feat: adds unit tests in modules/ee/teams (#5620) 2025-05-06 12:31:43 +00:00
Piyush Gupta
cf5bc51e94 fix: strict recaptcha checks (#5674) 2025-05-06 12:13:28 +00:00
Dhruwang Jariwala
9a7d24ea4e chore: updated open telemtry package versions (#5672) 2025-05-06 11:59:54 +00:00
138 changed files with 14213 additions and 1490 deletions

View File

@@ -49,7 +49,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies

View File

@@ -1,27 +0,0 @@
name: "Pull Request Labeler"
on:
- pull_request_target
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
labeler:
name: Pull Request Labeler
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
sync-labels: ""

View File

@@ -26,7 +26,7 @@ jobs:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64

View File

@@ -29,7 +29,7 @@ jobs:
node-version: 22.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64

View File

@@ -26,10 +26,10 @@
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.31.1",
"@typescript-eslint/parser": "8.31.1",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.2",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.6.12",

View File

@@ -18,8 +18,9 @@ FROM node:22-alpine3.21 AS base
FROM base AS installer
# Enable corepack and prepare pnpm
RUN npm install -g corepack@latest
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
@@ -59,7 +60,7 @@ COPY . .
RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install
RUN pnpm install --ignore-scripts
# Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers
@@ -75,7 +76,7 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install -g corepack@latest
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN apk add --no-cache curl \
@@ -141,12 +142,13 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
# USER nextjs
USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/

View File

@@ -0,0 +1,138 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import EnvironmentPage from "./page";
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("EnvironmentPage", () => {
afterEach(() => {
vi.clearAllMocks();
});
const mockEnvironmentId = "test-environment-id";
const mockUserId = "test-user-id";
const mockOrganizationId = "test-organization-id";
const mockSession = {
user: {
id: mockUserId,
name: "Test User",
email: "test@example.com",
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
emailVerified: new Date(),
role: "user",
objective: "other",
},
expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
} as any;
const mockOrganization: TOrganization = {
id: mockOrganizationId,
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: "cus_123",
} as unknown as TOrganizationBilling,
} as unknown as TOrganization;
test("should redirect to billing settings if isBilling is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: mockSession,
organization: mockOrganization,
environment: { id: mockEnvironmentId, type: "production", product: { id: "prodId" } },
} as any); // Using 'any' for brevity as environment type is complex and not core to this test
const mockMembership: TMembership = {
userId: mockUserId,
organizationId: mockOrganizationId,
role: "owner" as any,
accepted: true,
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({ isBilling: true, isOwner: true } as any);
await EnvironmentPage({ params: { environmentId: mockEnvironmentId } });
expect(redirect).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
});
test("should redirect to surveys if isBilling is false", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: mockSession,
organization: mockOrganization,
environment: { id: mockEnvironmentId, type: "production", product: { id: "prodId" } },
} as any);
const mockMembership: TMembership = {
userId: mockUserId,
organizationId: mockOrganizationId,
role: "developer" as any, // Role that would result in isBilling: false
accepted: true,
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({ isBilling: false, isOwner: false } as any);
await EnvironmentPage({ params: { environmentId: mockEnvironmentId } });
expect(redirect).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/surveys`);
});
test("should handle session being null", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: null, // Simulate no active session
organization: mockOrganization,
environment: { id: mockEnvironmentId, type: "production", product: { id: "prodId" } },
} as any);
// Membership fetch might return null or throw, depending on implementation when userId is undefined
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
// Access flags would likely be all false if membership is null
vi.mocked(getAccessFlags).mockReturnValue({ isBilling: false, isOwner: false } as any);
await EnvironmentPage({ params: { environmentId: mockEnvironmentId } });
// Expect redirect to surveys as default when isBilling is false
expect(redirect).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/surveys`);
});
test("should handle currentUserMembership being null", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: mockSession,
organization: mockOrganization,
environment: { id: mockEnvironmentId, type: "production", product: { id: "prodId" } },
} as any);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null); // Simulate no membership found
// Access flags would likely be all false if membership is null
vi.mocked(getAccessFlags).mockReturnValue({ isBilling: false, isOwner: false } as any);
await EnvironmentPage({ params: { environmentId: mockEnvironmentId } });
// Expect redirect to surveys as default when isBilling is false
expect(redirect).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/surveys`);
});
});

View File

@@ -0,0 +1,15 @@
import { AppConnectionLoading as OriginalAppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
import { describe, expect, test, vi } from "vitest";
import AppConnectionLoading from "./loading";
// Mock the original component to ensure we are testing the re-export
vi.mock("@/modules/projects/settings/(setup)/app-connection/loading", () => ({
AppConnectionLoading: () => <div data-testid="mock-app-connection-loading">Mock AppConnectionLoading</div>,
}));
describe("AppConnectionLoading Re-export", () => {
test("should re-export AppConnectionLoading from the correct module", () => {
// Check if the re-exported component is the same as the original (mocked) component
expect(AppConnectionLoading).toBe(OriginalAppConnectionLoading);
});
});

View File

@@ -0,0 +1,33 @@
import { AppConnectionPage as OriginalAppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
import { describe, expect, test, vi } from "vitest";
import AppConnectionPage from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
describe("AppConnectionPage Re-export", () => {
test("should re-export AppConnectionPage correctly", () => {
expect(AppConnectionPage).toBe(OriginalAppConnectionPage);
});
});

View File

@@ -0,0 +1,17 @@
import { GeneralSettingsLoading as OriginalGeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
import { describe, expect, test, vi } from "vitest";
import GeneralSettingsLoadingPage from "./loading";
// Mock the original component to ensure we are testing the re-export
vi.mock("@/modules/projects/settings/general/loading", () => ({
GeneralSettingsLoading: () => (
<div data-testid="mock-general-settings-loading">Mock GeneralSettingsLoading</div>
),
}));
describe("GeneralSettingsLoadingPage Re-export", () => {
test("should re-export GeneralSettingsLoading from the correct module", () => {
// Check if the re-exported component is the same as the original (mocked) component
expect(GeneralSettingsLoadingPage).toBe(OriginalGeneralSettingsLoading);
});
});

View File

@@ -0,0 +1,33 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
describe("GeneralSettingsPage re-export", () => {
test("should re-export GeneralSettingsPage component", () => {
expect(Page).toBe(GeneralSettingsPage);
});
});

View File

@@ -0,0 +1,15 @@
import { LanguagesLoading as OriginalLanguagesLoading } from "@/modules/ee/languages/loading";
import { describe, expect, test, vi } from "vitest";
import LanguagesLoading from "./loading";
// Mock the original component to ensure we are testing the re-export
vi.mock("@/modules/ee/languages/loading", () => ({
LanguagesLoading: () => <div data-testid="mock-languages-loading">Mock LanguagesLoading</div>,
}));
describe("LanguagesLoadingPage Re-export", () => {
test("should re-export LanguagesLoading from the correct module", () => {
// Check if the re-exported component is the same as the original (mocked) component
expect(LanguagesLoading).toBe(OriginalLanguagesLoading);
});
});

View File

@@ -0,0 +1,33 @@
import { LanguagesPage } from "@/modules/ee/languages/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
describe("LanguagesPage re-export", () => {
test("should re-export LanguagesPage component", () => {
expect(Page).toBe(LanguagesPage);
});
});

View File

@@ -0,0 +1,24 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import ProjectLayout, { metadata as layoutMetadata } from "./layout";
vi.mock("@/modules/projects/settings/layout", () => ({
ProjectSettingsLayout: ({ children }) => <div data-testid="project-settings-layout">{children}</div>,
metadata: { title: "Mocked Project Settings" },
}));
describe("ProjectLayout", () => {
afterEach(() => {
cleanup();
});
test("renders ProjectSettingsLayout", () => {
const { getByTestId } = render(<ProjectLayout>Child Content</ProjectLayout>);
expect(getByTestId("project-settings-layout")).toBeInTheDocument();
expect(getByTestId("project-settings-layout")).toHaveTextContent("Child Content");
});
test("exports metadata from @/modules/projects/settings/layout", () => {
expect(layoutMetadata).toEqual({ title: "Mocked Project Settings" });
});
});

View File

@@ -0,0 +1,17 @@
import { ProjectLookSettingsLoading as OriginalProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
import { describe, expect, test, vi } from "vitest";
import ProjectLookSettingsLoading from "./loading";
// Mock the original component to ensure we are testing the re-export
vi.mock("@/modules/projects/settings/look/loading", () => ({
ProjectLookSettingsLoading: () => (
<div data-testid="mock-project-look-settings-loading">Mock ProjectLookSettingsLoading</div>
),
}));
describe("ProjectLookSettingsLoadingPage Re-export", () => {
test("should re-export ProjectLookSettingsLoading from the correct module", () => {
// Check if the re-exported component is the same as the original (mocked) component
expect(ProjectLookSettingsLoading).toBe(OriginalProjectLookSettingsLoading);
});
});

View File

@@ -0,0 +1,33 @@
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
describe("ProjectLookSettingsPage re-export", () => {
test("should re-export ProjectLookSettingsPage component", () => {
expect(Page).toBe(ProjectLookSettingsPage);
});
});

View File

@@ -0,0 +1,33 @@
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
describe("ProjectSettingsPage re-export", () => {
test("should re-export ProjectSettingsPage component", () => {
expect(Page).toBe(ProjectSettingsPage);
});
});

View File

@@ -0,0 +1,15 @@
import { TagsLoading as OriginalTagsLoading } from "@/modules/projects/settings/tags/loading";
import { describe, expect, test, vi } from "vitest";
import TagsLoading from "./loading";
// Mock the original component to ensure we are testing the re-export
vi.mock("@/modules/projects/settings/tags/loading", () => ({
TagsLoading: () => <div data-testid="mock-tags-loading">Mock TagsLoading</div>,
}));
describe("TagsLoadingPage Re-export", () => {
test("should re-export TagsLoading from the correct module", () => {
// Check if the re-exported component is the same as the original (mocked) component
expect(TagsLoading).toBe(OriginalTagsLoading);
});
});

View File

@@ -0,0 +1,33 @@
import { TagsPage } from "@/modules/projects/settings/tags/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
describe("TagsPage re-export", () => {
test("should re-export TagsPage component", () => {
expect(Page).toBe(TagsPage);
});
});

View File

@@ -0,0 +1,33 @@
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
describe("ProjectTeams re-export", () => {
test("should re-export ProjectTeams component", () => {
expect(Page).toBe(ProjectTeams);
});
});

View File

@@ -0,0 +1,148 @@
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { cleanup, render } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AccountSettingsNavbar } from "./AccountSettingsNavbar";
vi.mock("next/navigation", () => ({
usePathname: vi.fn(),
}));
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div>SecondaryNavigationMock</div>),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
if (key === "common.profile") return "Profile";
if (key === "common.notifications") return "Notifications";
return key;
},
}),
}));
describe("AccountSettingsNavbar", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
});
test("renders correctly and sets profile as current when pathname includes /profile", () => {
vi.mocked(usePathname).mockReturnValue("/environments/testEnvId/settings/profile");
render(<AccountSettingsNavbar environmentId="testEnvId" activeId="profile" />);
expect(SecondaryNavigation).toHaveBeenCalledWith(
{
navigation: [
{
id: "profile",
label: "Profile",
href: "/environments/testEnvId/settings/profile",
current: true,
},
{
id: "notifications",
label: "Notifications",
href: "/environments/testEnvId/settings/notifications",
current: false,
},
],
activeId: "profile",
loading: undefined,
},
undefined
);
});
test("sets notifications as current when pathname includes /notifications", () => {
vi.mocked(usePathname).mockReturnValue("/environments/testEnvId/settings/notifications");
render(<AccountSettingsNavbar environmentId="testEnvId" activeId="notifications" />);
expect(SecondaryNavigation).toHaveBeenCalledWith(
expect.objectContaining({
navigation: [
{
id: "profile",
label: "Profile",
href: "/environments/testEnvId/settings/profile",
current: false,
},
{
id: "notifications",
label: "Notifications",
href: "/environments/testEnvId/settings/notifications",
current: true,
},
],
activeId: "notifications",
}),
undefined
);
});
test("passes loading prop to SecondaryNavigation", () => {
vi.mocked(usePathname).mockReturnValue("/environments/testEnvId/settings/profile");
render(<AccountSettingsNavbar environmentId="testEnvId" activeId="profile" loading={true} />);
expect(SecondaryNavigation).toHaveBeenCalledWith(
expect.objectContaining({
loading: true,
}),
undefined
);
});
test("handles undefined environmentId gracefully in hrefs", () => {
vi.mocked(usePathname).mockReturnValue("/environments/undefined/settings/profile");
render(<AccountSettingsNavbar activeId="profile" />); // environmentId is undefined
expect(SecondaryNavigation).toHaveBeenCalledWith(
expect.objectContaining({
navigation: [
{
id: "profile",
label: "Profile",
href: "/environments/undefined/settings/profile",
current: true,
},
{
id: "notifications",
label: "Notifications",
href: "/environments/undefined/settings/notifications",
current: false,
},
],
}),
undefined
);
});
test("handles null pathname gracefully", () => {
vi.mocked(usePathname).mockReturnValue("");
render(<AccountSettingsNavbar environmentId="testEnvId" activeId="profile" />);
expect(SecondaryNavigation).toHaveBeenCalledWith(
expect.objectContaining({
navigation: [
{
id: "profile",
label: "Profile",
href: "/environments/testEnvId/settings/profile",
current: false,
},
{
id: "notifications",
label: "Notifications",
href: "/environments/testEnvId/settings/notifications",
current: false,
},
],
}),
undefined
);
});
});

View File

@@ -0,0 +1,95 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { cleanup, render, screen } from "@testing-library/react";
import { Session, getServerSession } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import AccountSettingsLayout from "./layout";
// Mock dependencies
vi.mock("@/lib/organization/service");
vi.mock("@/lib/project/service");
vi.mock("next-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("next-auth")>();
return {
...actual,
getServerSession: vi.fn(),
};
});
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);
const mockGetProjectByEnvironmentId = vi.mocked(getProjectByEnvironmentId);
const mockGetServerSession = vi.mocked(getServerSession);
const mockOrganization = { id: "org_test_id" } as unknown as TOrganization;
const mockProject = { id: "project_test_id" } as unknown as TProject;
const mockSession = { user: { id: "user_test_id" } } as unknown as Session;
const t = (key: any) => key;
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => t,
}));
const mockProps = {
params: { environmentId: "env_test_id" },
children: <div>Child Content</div>,
};
describe("AccountSettingsLayout", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.resetAllMocks();
mockGetOrganizationByEnvironmentId.mockResolvedValue(mockOrganization);
mockGetProjectByEnvironmentId.mockResolvedValue(mockProject);
mockGetServerSession.mockResolvedValue(mockSession);
});
test("should render children when all data is fetched successfully", async () => {
render(await AccountSettingsLayout(mockProps));
expect(screen.getByText("Child Content")).toBeInTheDocument();
});
test("should throw error if organization is not found", async () => {
mockGetOrganizationByEnvironmentId.mockResolvedValue(null);
await expect(AccountSettingsLayout(mockProps)).rejects.toThrowError("common.organization_not_found");
});
test("should throw error if project is not found", async () => {
mockGetProjectByEnvironmentId.mockResolvedValue(null);
await expect(AccountSettingsLayout(mockProps)).rejects.toThrowError("common.project_not_found");
});
test("should throw error if session is not found", async () => {
mockGetServerSession.mockResolvedValue(null);
await expect(AccountSettingsLayout(mockProps)).rejects.toThrowError("common.session_not_found");
});
});

View File

@@ -0,0 +1,268 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { EditAlerts } from "./EditAlerts";
// Mock dependencies
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-provider">{children}</div>
),
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-trigger">{children}</div>
),
}));
vi.mock("lucide-react", () => ({
HelpCircleIcon: () => <div data-testid="help-circle-icon" />,
UsersIcon: () => <div data-testid="users-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href} data-testid="link">
{children}
</a>
),
}));
const mockNotificationSwitch = vi.fn();
vi.mock("./NotificationSwitch", () => ({
NotificationSwitch: (props: any) => {
mockNotificationSwitch(props);
return (
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
NotificationSwitch
</div>
);
},
}));
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
role: "project_manager",
objective: "other",
emailVerified: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
identityProvider: "email",
twoFactorEnabled: false,
} as unknown as TUser;
const mockMemberships: Membership[] = [
{
organization: {
id: "org1",
name: "Organization 1",
projects: [
{
id: "proj1",
name: "Project 1",
environments: [
{
id: "env1",
surveys: [
{ id: "survey1", name: "Survey 1 Org 1 Proj 1" },
{ id: "survey2", name: "Survey 2 Org 1 Proj 1" },
],
},
],
},
{
id: "proj2",
name: "Project 2",
environments: [
{
id: "env2",
surveys: [{ id: "survey3", name: "Survey 3 Org 1 Proj 2" }],
},
],
},
],
},
},
{
organization: {
id: "org2",
name: "Organization 2",
projects: [
{
id: "proj3",
name: "Project 3",
environments: [
{
id: "env3",
surveys: [{ id: "survey4", name: "Survey 4 Org 2 Proj 3" }],
},
],
},
],
},
},
{
organization: {
id: "org3",
name: "Organization 3 No Surveys",
projects: [
{
id: "proj4",
name: "Project 4",
environments: [
{
id: "env4",
surveys: [], // No surveys in this environment
},
],
},
],
},
},
];
const environmentId = "test-env-id";
const autoDisableNotificationType = "someType";
const autoDisableNotificationElementId = "someElementId";
describe("EditAlerts", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with multiple memberships and surveys", () => {
render(
<EditAlerts
memberships={mockMemberships}
user={mockUser}
environmentId={environmentId}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
);
// Check organization names
expect(screen.getByText("Organization 1")).toBeInTheDocument();
expect(screen.getByText("Organization 2")).toBeInTheDocument();
expect(screen.getByText("Organization 3 No Surveys")).toBeInTheDocument();
// Check survey names and project names as subtext
expect(screen.getByText("Survey 1 Org 1 Proj 1")).toBeInTheDocument();
expect(screen.getAllByText("Project 1")[0]).toBeInTheDocument(); // Project name under survey
expect(screen.getByText("Survey 2 Org 1 Proj 1")).toBeInTheDocument();
expect(screen.getByText("Survey 3 Org 1 Proj 2")).toBeInTheDocument();
expect(screen.getAllByText("Project 2")[0]).toBeInTheDocument();
expect(screen.getByText("Survey 4 Org 2 Proj 3")).toBeInTheDocument();
expect(screen.getAllByText("Project 3")[0]).toBeInTheDocument();
// Check "No surveys found" message for org3
const org3Heading = screen.getByText("Organization 3 No Surveys");
expect(org3Heading.parentElement?.parentElement?.parentElement).toHaveTextContent(
"common.no_surveys_found"
);
// Check NotificationSwitch calls
// Org 1 auto-subscribe
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "org1",
notificationType: "unsubscribedOrganizationIds",
autoDisableNotificationType,
autoDisableNotificationElementId,
})
);
// Survey 1
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "survey1",
notificationType: "alert",
autoDisableNotificationType,
autoDisableNotificationElementId,
})
);
// Survey 4
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "survey4",
notificationType: "alert",
autoDisableNotificationType,
autoDisableNotificationElementId,
})
);
// Check tooltip
expect(screen.getAllByTestId("tooltip-provider").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("tooltip").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("tooltip-trigger").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("tooltip-content")[0]).toHaveTextContent(
"environments.settings.notifications.every_response_tooltip"
);
expect(screen.getAllByTestId("help-circle-icon").length).toBeGreaterThan(0);
// Check invite link
const inviteLinks = screen.getAllByTestId("link");
const specificInviteLink = inviteLinks.find(
(link) => link.getAttribute("href") === `/environments/${environmentId}/settings/general`
);
expect(specificInviteLink).toBeInTheDocument();
expect(specificInviteLink).toHaveTextContent("common.invite_them");
// Check UsersIcon
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
});
test("renders correctly when a membership has no surveys", () => {
const singleMembershipNoSurveys: Membership[] = [
{
organization: {
id: "org-no-survey",
name: "Org Without Surveys",
projects: [
{
id: "proj-no-survey",
name: "Project Without Surveys",
environments: [
{
id: "env-no-survey",
surveys: [],
},
],
},
],
},
},
];
render(
<EditAlerts
memberships={singleMembershipNoSurveys}
user={mockUser}
environmentId={environmentId}
autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
);
expect(screen.getByText("Org Without Surveys")).toBeInTheDocument();
expect(screen.getByText("common.no_surveys_found")).toBeInTheDocument();
expect(screen.queryByText("Survey 1 Org 1 Proj 1")).not.toBeInTheDocument(); // Ensure other surveys aren't rendered
// Check NotificationSwitch for organization auto-subscribe
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "org-no-survey",
notificationType: "unsubscribedOrganizationIds",
})
);
});
});

View File

@@ -0,0 +1,166 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { Membership } from "../types";
import { EditWeeklySummary } from "./EditWeeklySummary";
vi.mock("lucide-react", () => ({
UsersIcon: () => <div data-testid="users-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href} data-testid="link">
{children}
</a>
),
}));
const mockNotificationSwitch = vi.fn();
vi.mock("./NotificationSwitch", () => ({
NotificationSwitch: (props: any) => {
mockNotificationSwitch(props);
return (
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
NotificationSwitch
</div>
);
},
}));
const mockT = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockT,
}),
}));
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {
proj1: true,
proj3: false,
},
unsubscribedOrganizationIds: [],
},
role: "project_manager",
objective: "other",
emailVerified: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
identityProvider: "email",
twoFactorEnabled: false,
} as unknown as TUser;
const mockMemberships: Membership[] = [
{
organization: {
id: "org1",
name: "Organization 1",
projects: [
{ id: "proj1", name: "Project 1", environments: [] },
{ id: "proj2", name: "Project 2", environments: [] },
],
},
},
{
organization: {
id: "org2",
name: "Organization 2",
projects: [{ id: "proj3", name: "Project 3", environments: [] }],
},
},
];
const environmentId = "test-env-id";
describe("EditWeeklySummary", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with multiple memberships and projects", () => {
render(<EditWeeklySummary memberships={mockMemberships} user={mockUser} environmentId={environmentId} />);
expect(screen.getByText("Organization 1")).toBeInTheDocument();
expect(screen.getByText("Project 1")).toBeInTheDocument();
expect(screen.getByText("Project 2")).toBeInTheDocument();
expect(screen.getByText("Organization 2")).toBeInTheDocument();
expect(screen.getByText("Project 3")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj1",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj1")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj2",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj2")).toBeInTheDocument();
expect(mockNotificationSwitch).toHaveBeenCalledWith(
expect.objectContaining({
surveyOrProjectOrOrganizationId: "proj3",
notificationSettings: mockUser.notificationSettings,
notificationType: "weeklySummary",
})
);
expect(screen.getByTestId("notification-switch-proj3")).toBeInTheDocument();
const inviteLinks = screen.getAllByTestId("link");
expect(inviteLinks.length).toBe(mockMemberships.length);
inviteLinks.forEach((link) => {
expect(link).toHaveAttribute("href", `/environments/${environmentId}/settings/general`);
expect(link).toHaveTextContent("common.invite_them");
});
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
expect(screen.getAllByText("common.project")[0]).toBeInTheDocument();
expect(screen.getAllByText("common.weekly_summary")[0]).toBeInTheDocument();
expect(
screen.getAllByText("environments.settings.notifications.want_to_loop_in_organization_mates?").length
).toBe(mockMemberships.length);
});
test("renders correctly with no memberships", () => {
render(<EditWeeklySummary memberships={[]} user={mockUser} environmentId={environmentId} />);
expect(screen.queryByText("Organization 1")).not.toBeInTheDocument();
expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument();
});
test("renders correctly when an organization has no projects", () => {
const membershipsWithNoProjects: Membership[] = [
{
organization: {
id: "org3",
name: "Organization No Projects",
projects: [],
},
},
];
render(
<EditWeeklySummary
memberships={membershipsWithNoProjects}
user={mockUser}
environmentId={environmentId}
/>
);
expect(screen.getByText("Organization No Projects")).toBeInTheDocument();
expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); // Check that no projects are listed under it
expect(mockNotificationSwitch).not.toHaveBeenCalled(); // No projects, so no switches for projects
});
});

View File

@@ -0,0 +1,36 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { IntegrationsTip } from "./IntegrationsTip";
vi.mock("@/modules/ui/components/icons", () => ({
SlackIcon: () => <div data-testid="slack-icon" />,
}));
const mockT = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockT,
}),
}));
const environmentId = "test-env-id";
describe("IntegrationsTip", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the component with correct text and link", () => {
render(<IntegrationsTip environmentId={environmentId} />);
expect(screen.getByTestId("slack-icon")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.need_slack_or_discord_notifications?")
).toBeInTheDocument();
const linkElement = screen.getByText("environments.settings.notifications.use_the_integration");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", `/environments/${environmentId}/integrations`);
});
});

View File

@@ -0,0 +1,249 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { updateNotificationSettingsAction } from "../actions";
import { NotificationSwitch } from "./NotificationSwitch";
vi.mock("@/modules/ui/components/switch", () => ({
Switch: vi.fn(({ checked, disabled, onCheckedChange, id, "aria-label": ariaLabel }) => (
<input
type="checkbox"
data-testid={id}
aria-label={ariaLabel}
checked={checked}
disabled={disabled}
onChange={onCheckedChange}
/>
)),
}));
vi.mock("../actions", () => ({
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()),
}));
const surveyId = "survey1";
const projectId = "project1";
const organizationId = "org1";
const baseNotificationSettings: TUserNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
describe("NotificationSwitch", () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
const renderSwitch = (props: Partial<React.ComponentProps<typeof NotificationSwitch>>) => {
const defaultProps: React.ComponentProps<typeof NotificationSwitch> = {
surveyOrProjectOrOrganizationId: surveyId,
notificationSettings: JSON.parse(JSON.stringify(baseNotificationSettings)),
notificationType: "alert",
};
return render(<NotificationSwitch {...defaultProps} {...props} />);
};
test("renders with initial checked state for 'alert' (true)", () => {
const settings = { ...baseNotificationSettings, alert: { [surveyId]: true } };
renderSwitch({ notificationSettings: settings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert") as HTMLInputElement;
expect(switchInput.checked).toBe(true);
});
test("renders with initial checked state for 'alert' (false)", () => {
const settings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: settings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert") as HTMLInputElement;
expect(switchInput.checked).toBe(false);
});
test("renders with initial checked state for 'weeklySummary' (true)", () => {
const settings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: settings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText(
"toggle notification settings for weeklySummary"
) as HTMLInputElement;
expect(switchInput.checked).toBe(true);
});
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: settings,
notificationType: "unsubscribedOrganizationIds",
});
const switchInput = screen.getByLabelText(
"toggle notification settings for unsubscribedOrganizationIds"
) as HTMLInputElement;
expect(switchInput.checked).toBe(true); // Not in unsubscribed list means subscribed
});
test("renders with initial checked state for 'unsubscribedOrganizationIds' (unsubscribed initially, so checked is false)", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [organizationId] };
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: settings,
notificationType: "unsubscribedOrganizationIds",
});
const switchInput = screen.getByLabelText(
"toggle notification settings for unsubscribedOrganizationIds"
) as HTMLInputElement;
expect(switchInput.checked).toBe(false); // In unsubscribed list means unsubscribed
});
test("handles switch change for 'alert' type", async () => {
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.success).toHaveBeenCalledWith(
"environments.settings.notifications.notification_settings_updated",
{ id: "notification-switch" }
);
expect(switchInput).toBeEnabled(); // Check if not disabled after action
});
test("handles switch change for 'unsubscribedOrganizationIds' (subscribe)", async () => {
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [organizationId] }; // initially unsubscribed
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: initialSettings,
notificationType: "unsubscribedOrganizationIds",
});
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [] }, // should be removed from list
});
expect(toast.success).toHaveBeenCalledWith(
"environments.settings.notifications.notification_settings_updated",
{ id: "notification-switch" }
);
});
test("handles switch change for 'unsubscribedOrganizationIds' (unsubscribe)", async () => {
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] }; // initially subscribed
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: initialSettings,
notificationType: "unsubscribedOrganizationIds",
});
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] }, // should be added to list
});
expect(toast.success).toHaveBeenCalledWith(
"environments.settings.notifications.notification_settings_updated",
{ id: "notification-switch" }
);
});
test("useEffect: auto-disables 'alert' notification if conditions met", () => {
const settings = { ...baseNotificationSettings, alert: { [surveyId]: true } }; // Initially true
renderSwitch({
surveyOrProjectOrOrganizationId: surveyId,
notificationSettings: settings,
notificationType: "alert",
autoDisableNotificationType: "alert",
autoDisableNotificationElementId: surveyId,
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...settings, alert: { [surveyId]: false } },
});
expect(toast.success).toHaveBeenCalledWith(
"environments.settings.notifications.you_will_not_receive_any_more_emails_for_responses_on_this_survey",
{ id: "notification-switch" }
);
});
test("useEffect: auto-disables 'unsubscribedOrganizationIds' (auto-unsubscribes) if conditions met", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] }; // Initially subscribed
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: settings,
notificationType: "unsubscribedOrganizationIds",
autoDisableNotificationType: "someOtherType", // This prop is used to trigger the effect, not directly for type matching in this case
autoDisableNotificationElementId: organizationId,
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...settings, unsubscribedOrganizationIds: [organizationId] },
});
expect(toast.success).toHaveBeenCalledWith(
"environments.settings.notifications.you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore",
{ id: "notification-switch" }
);
});
test("useEffect: does not auto-disable if 'autoDisableNotificationElementId' does not match", () => {
const settings = { ...baseNotificationSettings, alert: { [surveyId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: surveyId,
notificationSettings: settings,
notificationType: "alert",
autoDisableNotificationType: "alert",
autoDisableNotificationElementId: "otherId", // Mismatch
});
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalledWith(
"environments.settings.notifications.you_will_not_receive_any_more_emails_for_responses_on_this_survey"
);
});
test("useEffect: does not auto-disable if not checked initially for 'alert'", () => {
const settings = { ...baseNotificationSettings, alert: { [surveyId]: false } }; // Initially false
renderSwitch({
surveyOrProjectOrOrganizationId: surveyId,
notificationSettings: settings,
notificationType: "alert",
autoDisableNotificationType: "alert",
autoDisableNotificationElementId: surveyId,
});
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
});
test("useEffect: does not auto-disable if not checked initially for 'unsubscribedOrganizationIds' (already unsubscribed)", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [organizationId] }; // Initially unsubscribed
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: settings,
notificationType: "unsubscribedOrganizationIds",
autoDisableNotificationType: "someType",
autoDisableNotificationElementId: organizationId,
});
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,50 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="page-content-wrapper">{children}</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
}));
describe("Loading Notifications Settings", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
const pageHeader = screen.getByTestId("page-header");
expect(pageHeader).toBeInTheDocument();
expect(pageHeader).toHaveTextContent("common.account_settings");
// Check for Alerts LoadingCard
expect(screen.getByText("environments.settings.notifications.email_alerts_surveys")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses")
).toBeInTheDocument();
const alertsCard = screen
.getByText("environments.settings.notifications.email_alerts_surveys")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(alertsCard).toBeInTheDocument();
// Check for Weekly Summary LoadingCard
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
const weeklySummaryCard = screen
.getByText("environments.settings.notifications.weekly_summary_projects")
.closest("div[class*='rounded-xl']"); // Find parent card
expect(weeklySummaryCard).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,258 @@
import { getUser } from "@/lib/user/service";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import Page from "./page";
import { Membership } from "./types";
// Mock external dependencies
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar",
() => ({
AccountSettingsNavbar: ({ activeId }) => <div>AccountSettingsNavbar activeId={activeId}</div>,
})
);
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ title, description, children }) => (
<div>
<h1>{title}</h1>
<p>{description}</p>
{children}
</div>
),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, children }) => (
<div>
<h1>{pageTitle}</h1>
{children}
</div>
),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
findMany: vi.fn(),
},
},
}));
vi.mock("./components/EditAlerts", () => ({
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
}));
vi.mock("./components/EditWeeklySummary", () => ({
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
}));
vi.mock("./components/IntegrationsTip", () => ({
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
}));
const mockUser: Partial<TUser> = {
id: "user-1",
name: "Test User",
email: "test@example.com",
notificationSettings: {
alert: { "survey-old": true },
weeklySummary: { "project-old": true },
unsubscribedOrganizationIds: ["org-unsubscribed"],
},
};
const mockMemberships: Membership[] = [
{
organization: {
id: "org-1",
name: "Org 1",
projects: [
{
id: "project-1",
name: "Project 1",
environments: [
{
id: "env-prod-1",
surveys: [
{ id: "survey-1", name: "Survey 1" },
{ id: "survey-2", name: "Survey 2" },
],
},
],
},
],
},
},
];
const mockSession = {
user: {
id: "user-1",
},
} as any;
const mockParams = { environmentId: "env-1" };
const mockSearchParams = {
type: "alertTest",
elementId: "elementTestId",
};
describe("NotificationsPage", () => {
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
beforeEach(() => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getUser).mockResolvedValue(mockUser as TUser);
vi.mocked(prisma.membership.findMany).mockResolvedValue(mockMemberships as any); // Prisma types can be complex
});
test("renders correctly with user and memberships, and processes notification settings", async () => {
const props = { params: mockParams, searchParams: mockSearchParams };
const PageComponent = await Page(props);
render(PageComponent);
expect(screen.getByText("common.account_settings")).toBeInTheDocument();
expect(screen.getByText("AccountSettingsNavbar activeId=notifications")).toBeInTheDocument();
expect(screen.getByText("environments.settings.notifications.email_alerts_surveys")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses")
).toBeInTheDocument();
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.weekly_summary_projects")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
// after `setCompleteNotificationSettings` processes it.
// We verify the structure and defaults.
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
expect(editAlertsCall.user.notificationSettings.alert["survey-1"]).toBe(false);
expect(editAlertsCall.user.notificationSettings.alert["survey-2"]).toBe(false);
// If "survey-old" was not part of any membership survey, it might be removed or kept depending on exact logic.
// The current logic only adds keys from memberships. So "survey-old" would be gone from .alert
// Let's adjust expectation based on `setCompleteNotificationSettings`
// It iterates memberships, then projects, then environments, then surveys.
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
// This means only survey IDs found in memberships will be in the new `alert` object.
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
const finalExpectedSettings = {
alert: {
"survey-1": false,
"survey-2": false,
},
weeklySummary: {
"project-1": false,
},
unsubscribedOrganizationIds: ["org-unsubscribed"],
};
expect(editAlertsCall.user.notificationSettings).toEqual(finalExpectedSettings);
expect(editAlertsCall.memberships).toEqual(mockMemberships);
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(finalExpectedSettings);
expect(editWeeklySummaryCall.memberships).toEqual(mockMemberships);
expect(editWeeklySummaryCall.environmentId).toBe(mockParams.environmentId);
});
test("throws error if session is not found", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const props = { params: mockParams, searchParams: {} };
await expect(Page(props)).rejects.toThrow("common.session_not_found");
});
test("throws error if user is not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
const props = { params: mockParams, searchParams: {} };
await expect(Page(props)).rejects.toThrow("common.user_not_found");
});
test("renders with empty memberships and default notification settings", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
const userWithNoSpecificSettings = {
...mockUser,
notificationSettings: { unsubscribedOrganizationIds: [] }, // Start fresh
};
vi.mocked(getUser).mockResolvedValue(userWithNoSpecificSettings as unknown as TUser);
const props = { params: mockParams, searchParams: {} };
const PageComponent = await Page(props);
render(PageComponent);
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
const expectedEmptySettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editAlertsCall.memberships).toEqual([]);
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(expectedEmptySettings);
expect(editWeeklySummaryCall.memberships).toEqual([]);
});
test("handles legacy notification settings correctly", async () => {
const userWithLegacySettings: Partial<TUser> = {
id: "user-legacy",
notificationSettings: {
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
weeklySummary: { "project-1": true },
unsubscribedOrganizationIds: [],
} as any, // To allow legacy structure
};
vi.mocked(getUser).mockResolvedValue(userWithLegacySettings as TUser);
// Memberships define survey-1 and project-1
vi.mocked(prisma.membership.findMany).mockResolvedValue(mockMemberships as any);
const props = { params: mockParams, searchParams: {} };
const PageComponent = await Page(props);
render(PageComponent);
const expectedProcessedSettings = {
alert: {
"survey-1": true, // Should be true due to legacy setting
"survey-2": false, // Default for other surveys in membership
},
weeklySummary: {
"project-1": true, // From user's weeklySummary
},
unsubscribedOrganizationIds: [],
};
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
expect(editAlertsCall.user.notificationSettings).toEqual(expectedProcessedSettings);
});
});

View File

@@ -0,0 +1,70 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { AccountSecurity } from "./AccountSecurity";
vi.mock("@/modules/ee/two-factor-auth/components/enable-two-factor-modal", () => ({
EnableTwoFactorModal: ({ open }) =>
open ? <div data-testid="enable-2fa-modal">EnableTwoFactorModal</div> : null,
}));
vi.mock("@/modules/ee/two-factor-auth/components/disable-two-factor-modal", () => ({
DisableTwoFactorModal: ({ open }) =>
open ? <div data-testid="disable-2fa-modal">DisableTwoFactorModal</div> : null,
}));
const mockUser = {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
} as unknown as TUser;
describe("AccountSecurity", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.resetAllMocks();
});
test("renders correctly with 2FA disabled", () => {
render(<AccountSecurity user={{ ...mockUser, twoFactorEnabled: false }} />);
expect(screen.getByText("environments.settings.profile.two_factor_authentication")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.profile.two_factor_authentication_description")
).toBeInTheDocument();
expect(screen.getByRole("switch")).not.toBeChecked();
});
test("renders correctly with 2FA enabled", () => {
render(<AccountSecurity user={{ ...mockUser, twoFactorEnabled: true }} />);
expect(screen.getByRole("switch")).toBeChecked();
});
test("opens EnableTwoFactorModal when switch is turned on", async () => {
render(<AccountSecurity user={{ ...mockUser, twoFactorEnabled: false }} />);
const switchControl = screen.getByRole("switch");
await userEvent.click(switchControl);
expect(screen.getByTestId("enable-2fa-modal")).toBeInTheDocument();
});
test("opens DisableTwoFactorModal when switch is turned off", async () => {
render(<AccountSecurity user={{ ...mockUser, twoFactorEnabled: true }} />);
const switchControl = screen.getByRole("switch");
await userEvent.click(switchControl);
expect(screen.getByTestId("disable-2fa-modal")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,97 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccount } from "./DeleteAccount";
vi.mock("@/modules/account/components/DeleteAccountModal", () => ({
DeleteAccountModal: ({ open }) =>
open ? <div data-testid="delete-account-modal">DeleteAccountModal</div> : null,
}));
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
} as unknown as TUser;
const mockSession: Session = {
user: mockUser,
expires: new Date(Date.now() + 2 * 86400).toISOString(),
};
const mockOrganizations: TOrganization[] = [
{
id: "org1",
name: "Org 1",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: "cus_123",
} as unknown as TOrganization["billing"],
} as unknown as TOrganization,
];
describe("DeleteAccount", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.resetAllMocks();
});
test("renders correctly and opens modal on click", async () => {
render(
<DeleteAccount
session={mockSession}
IS_FORMBRICKS_CLOUD={true}
user={mockUser}
organizationsWithSingleOwner={[]}
isMultiOrgEnabled={true}
/>
);
expect(screen.getByText("environments.settings.profile.warning_cannot_undo")).toBeInTheDocument();
const deleteButton = screen.getByText("environments.settings.profile.confirm_delete_my_account");
expect(deleteButton).toBeEnabled();
await userEvent.click(deleteButton);
expect(screen.getByTestId("delete-account-modal")).toBeInTheDocument();
});
test("renders null if session is not provided", () => {
const { container } = render(
<DeleteAccount
session={null}
IS_FORMBRICKS_CLOUD={true}
user={mockUser}
organizationsWithSingleOwner={[]}
isMultiOrgEnabled={true}
/>
);
expect(container.firstChild).toBeNull();
});
test("enables delete button if multi-org enabled even if user is single owner", () => {
render(
<DeleteAccount
session={mockSession}
IS_FORMBRICKS_CLOUD={false}
user={mockUser}
organizationsWithSingleOwner={mockOrganizations}
isMultiOrgEnabled={true}
/>
);
const deleteButton = screen.getByText("environments.settings.profile.confirm_delete_my_account");
expect(deleteButton).toBeEnabled();
});
});

View File

@@ -0,0 +1,104 @@
import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import * as fileUploadHooks from "@/app/lib/fileUpload";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Session } from "next-auth";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { EditProfileAvatarForm } from "./EditProfileAvatarForm";
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: ({ imageUrl }) => <div data-testid="profile-avatar">{imageUrl || "No Avatar"}</div>,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateAvatarAction: vi.fn(),
removeAvatarAction: vi.fn(),
}));
vi.mock("@/app/lib/fileUpload", () => ({
handleFileUpload: vi.fn(),
}));
const mockSession: Session = {
user: { id: "user-id" },
expires: "session-expires-at",
};
const environmentId = "test-env-id";
describe("EditProfileAvatarForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({});
vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({});
vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({
url: "new-avatar.jpg",
error: undefined,
});
});
test("renders correctly without an existing image", () => {
render(<EditProfileAvatarForm session={mockSession} environmentId={environmentId} imageUrl={null} />);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar");
expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument();
expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument();
});
test("renders correctly with an existing image", () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg");
expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument();
expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument();
});
test("handles image removal successfully", async () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId });
});
});
test("shows error if removeAvatarAction fails", async () => {
vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error"));
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
"environments.settings.profile.avatar_update_failed"
);
});
});
});

View File

@@ -0,0 +1,117 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { updateUserAction } from "../actions";
import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
const mockUser = {
id: "test-user-id",
name: "Old Name",
email: "test@example.com",
locale: "en-US",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
} as unknown as TUser;
// Mock window.location.reload
const originalLocation = window.location;
beforeEach(() => {
vi.stubGlobal("location", {
...originalLocation,
reload: vi.fn(),
});
});
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateUserAction: vi.fn(),
}));
afterEach(() => {
vi.unstubAllGlobals();
});
describe("EditProfileDetailsForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders with initial user data and updates successfully", async () => {
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
render(<EditProfileDetailsForm user={mockUser} />);
const nameInput = screen.getByPlaceholderText("common.full_name");
expect(nameInput).toHaveValue(mockUser.name);
expect(screen.getByDisplayValue(mockUser.email)).toBeDisabled();
// Check initial language (English)
expect(screen.getByText("English (US)")).toBeInTheDocument();
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "New Name");
// Change language
const languageDropdownTrigger = screen.getByRole("button", { name: /English/ });
await userEvent.click(languageDropdownTrigger);
const germanOption = await screen.findByText("German"); // Assuming 'German' is an option
await userEvent.click(germanOption);
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeEnabled();
await userEvent.click(updateButton);
await waitFor(() => {
expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" });
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
"environments.settings.profile.profile_updated_successfully"
);
});
await waitFor(() => {
expect(window.location.reload).toHaveBeenCalled();
});
});
test("shows error toast if update fails", async () => {
const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} />);
const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Another Name");
const updateButton = screen.getByText("common.update");
await userEvent.click(updateButton);
await waitFor(() => {
expect(updateUserAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(`common.error: ${errorMessage}`);
});
});
test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} />);
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled();
const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.type(nameInput, " updated");
expect(updateButton).toBeEnabled();
});
});

View File

@@ -0,0 +1,63 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar",
() => ({
AccountSettingsNavbar: ({ activeId, loading }) => (
<div data-testid="account-settings-navbar">
AccountSettingsNavbar - active: {activeId}, loading: {loading?.toString()}
</div>
),
})
);
vi.mock("@/app/(app)/components/LoadingCard", () => ({
LoadingCard: ({ title, description }) => (
<div data-testid="loading-card">
<div>{title}</div>
<div>{description}</div>
</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, children }) => (
<div>
<h1>{pageTitle}</h1>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>{children}</div>,
}));
describe("Loading", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
expect(screen.getByText("common.account_settings")).toBeInTheDocument();
expect(screen.getByTestId("account-settings-navbar")).toHaveTextContent(
"AccountSettingsNavbar - active: profile, loading: true"
);
const loadingCards = screen.getAllByTestId("loading-card");
expect(loadingCards).toHaveLength(3);
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
expect(loadingCards[1]).toHaveTextContent("common.avatar");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
});
});

View File

@@ -0,0 +1,188 @@
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import Page from "./page";
// Mock services and utils
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
getIsTwoFactorAuthEnabled: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
const t = (key: any) => key;
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => t,
}));
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar",
() => ({
AccountSettingsNavbar: ({ environmentId, activeId }) => (
<div data-testid="account-settings-navbar">
AccountSettingsNavbar: {environmentId} {activeId}
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity",
() => ({
AccountSecurity: ({ user }) => <div data-testid="account-security">AccountSecurity: {user.id}</div>,
})
);
vi.mock("./components/DeleteAccount", () => ({
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
}));
vi.mock("./components/EditProfileAvatarForm", () => ({
EditProfileAvatarForm: ({ _, environmentId }) => (
<div data-testid="edit-profile-avatar-form">EditProfileAvatarForm: {environmentId}</div>
),
}));
vi.mock("./components/EditProfileDetailsForm", () => ({
EditProfileDetailsForm: ({ user }) => (
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: ({ title }) => <div data-testid="upgrade-prompt">{title}</div>,
}));
const mockUser = {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
} as unknown as TUser;
const mockSession: Session = {
user: mockUser,
expires: "never",
};
const mockOrganizations: TOrganization[] = [];
const params = { environmentId: "env-123" };
describe("ProfilePage", () => {
beforeEach(() => {
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue(mockOrganizations);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: mockSession,
} as unknown as TEnvironmentAuth);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getIsTwoFactorAuthEnabled).mockResolvedValue(true);
});
afterEach(() => {
vi.clearAllMocks();
cleanup();
});
test("renders profile page with all sections for email user with 2FA license", async () => {
render(await Page({ params: Promise.resolve(params) }));
await waitFor(() => {
expect(screen.getByText("common.account_settings")).toBeInTheDocument();
expect(screen.getByTestId("account-settings-navbar")).toHaveTextContent(
"AccountSettingsNavbar: env-123 profile"
);
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument();
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
// Use a regex to match the text content, allowing for variable whitespace
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
});
});
test("renders UpgradePrompt when 2FA license is disabled and user 2FA is off", async () => {
vi.mocked(getIsTwoFactorAuthEnabled).mockResolvedValue(false); // License disabled
const userWith2FAOff = { ...mockUser, twoFactorEnabled: false };
vi.mocked(getUser).mockResolvedValue(userWith2FAOff);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: { ...mockSession, user: userWith2FAOff },
} as unknown as TEnvironmentAuth);
render(await Page({ params: Promise.resolve(params) }));
await waitFor(() => {
expect(screen.getByTestId("upgrade-prompt")).toBeInTheDocument();
expect(screen.getByTestId("upgrade-prompt")).toHaveTextContent(
"environments.settings.profile.unlock_two_factor_authentication"
);
expect(screen.queryByTestId("account-security")).not.toBeInTheDocument();
});
});
test("renders AccountSecurity when 2FA license is disabled but user 2FA is on", async () => {
vi.mocked(getIsTwoFactorAuthEnabled).mockResolvedValue(false); // License disabled
const userWith2FAOn = { ...mockUser, twoFactorEnabled: true };
vi.mocked(getUser).mockResolvedValue(userWith2FAOn);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: { ...mockSession, user: userWith2FAOn },
} as unknown as TEnvironmentAuth);
render(await Page({ params: Promise.resolve(params) }));
await waitFor(() => {
expect(screen.getByTestId("account-security")).toBeInTheDocument();
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
});
});
test("does not render security card if identityProvider is not email", async () => {
const nonEmailUser = { ...mockUser, identityProvider: "google" as "email" | "github" | "google" }; // type assertion
vi.mocked(getUser).mockResolvedValue(nonEmailUser);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: { ...mockSession, user: nonEmailUser },
} as unknown as TEnvironmentAuth);
render(await Page({ params: Promise.resolve(params) }));
await waitFor(() => {
expect(screen.queryByTestId("account-security")).not.toBeInTheDocument();
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.queryByText("common.security")).not.toBeInTheDocument();
});
});
test("throws error if user is not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
// Need to catch the promise rejection for async component errors
try {
// We don't await the render directly, but the component execution
await Page({ params: Promise.resolve(params) });
} catch (e) {
expect(e.message).toBe("common.user_not_found");
}
});
});

View File

@@ -0,0 +1,29 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import LoadingPage from "./loading";
// Mock the IS_FORMBRICKS_CLOUD constant
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
}));
// Mock the actual Loading component that is being imported
vi.mock("@/modules/organization/settings/api-keys/loading", () => ({
default: ({ isFormbricksCloud }: { isFormbricksCloud: boolean }) => (
<div data-testid="mocked-loading-component">isFormbricksCloud: {String(isFormbricksCloud)}</div>
),
}));
describe("LoadingPage for API Keys", () => {
afterEach(() => {
cleanup();
});
test("renders the underlying Loading component with correct isFormbricksCloud prop", () => {
render(<LoadingPage />);
const mockedLoadingComponent = screen.getByTestId("mocked-loading-component");
expect(mockedLoadingComponent).toBeInTheDocument();
// Check if the prop is passed correctly based on the mocked constant value
expect(mockedLoadingComponent).toHaveTextContent("isFormbricksCloud: true");
});
});

View File

@@ -0,0 +1,21 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
// Mock the APIKeysPage component
vi.mock("@/modules/organization/settings/api-keys/page", () => ({
APIKeysPage: () => <div data-testid="mocked-api-keys-page">APIKeysPage Content</div>,
}));
describe("APIKeys Page", () => {
afterEach(() => {
cleanup();
});
test("renders the APIKeysPage component", () => {
render(<Page />);
const apiKeysPageComponent = screen.getByTestId("mocked-api-keys-page");
expect(apiKeysPageComponent).toBeInTheDocument();
expect(apiKeysPageComponent).toHaveTextContent("APIKeysPage Content");
});
});

View File

@@ -0,0 +1,74 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
}));
// Mock server-side translation
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
// Mock child components
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="page-content-wrapper">{children}</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, children }: { pageTitle: string; children: React.ReactNode }) => (
<div data-testid="page-header">
<h1>{pageTitle}</h1>
{children}
</div>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar",
() => ({
OrganizationSettingsNavbar: ({ activeId, loading }: { activeId: string; loading?: boolean }) => (
<div data-testid="org-settings-navbar">
Active: {activeId}, Loading: {String(loading)}
</div>
),
})
);
describe("Billing Loading Page", () => {
beforeEach(async () => {
const mockTranslate = vi.fn((key) => key);
vi.mocked(await import("@/tolgee/server")).getTranslate.mockResolvedValue(mockTranslate);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders PageContentWrapper, PageHeader, and OrganizationSettingsNavbar", async () => {
render(await Loading());
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
const pageHeader = screen.getByTestId("page-header");
expect(pageHeader).toBeInTheDocument();
expect(pageHeader).toHaveTextContent("environments.settings.general.organization_settings");
const navbar = screen.getByTestId("org-settings-navbar");
expect(navbar).toBeInTheDocument();
expect(navbar).toHaveTextContent("Active: billing");
expect(navbar).toHaveTextContent("Loading: true");
});
test("renders placeholder divs", async () => {
render(await Loading());
// Check for the presence of divs with animate-pulse, assuming they are the placeholders
const placeholders = screen.getAllByRole("generic", { hidden: true }); // Using a generic role as divs don't have implicit roles
const animatedPlaceholders = placeholders.filter((el) => el.classList.contains("animate-pulse"));
expect(animatedPlaceholders.length).toBeGreaterThanOrEqual(2); // Expecting at least two placeholder divs
});
});

View File

@@ -0,0 +1,21 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
// Mock the PricingPage component
vi.mock("@/modules/ee/billing/page", () => ({
PricingPage: () => <div data-testid="mocked-pricing-page">PricingPage Content</div>,
}));
describe("Billing Page", () => {
afterEach(() => {
cleanup();
});
test("renders the PricingPage component", () => {
render(<Page />);
const pricingPageComponent = screen.getByTestId("mocked-pricing-page");
expect(pricingPageComponent).toBeInTheDocument();
expect(pricingPageComponent).toHaveTextContent("PricingPage Content");
});
});

View File

@@ -0,0 +1,134 @@
import { getAccessFlags } from "@/lib/membership/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { OrganizationSettingsNavbar } from "./OrganizationSettingsNavbar";
vi.mock("next/navigation", () => ({
usePathname: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
// Mock SecondaryNavigation to inspect its props
let mockSecondaryNavigationProps: any;
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: (props: any) => {
mockSecondaryNavigationProps = props;
return <div data-testid="secondary-navigation">Mocked SecondaryNavigation</div>;
},
}));
describe("OrganizationSettingsNavbar", () => {
beforeEach(() => {
mockSecondaryNavigationProps = null; // Reset before each test
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const defaultProps = {
environmentId: "env123",
isFormbricksCloud: true,
membershipRole: "owner" as TOrganizationRole,
activeId: "general",
loading: false,
};
test.each([
{
pathname: "/environments/env123/settings/general",
role: "owner",
isCloud: true,
expectedVisibility: { general: true, billing: true, teams: true, enterprise: false, "api-keys": true },
},
{
pathname: "/environments/env123/settings/teams",
role: "member",
isCloud: false,
expectedVisibility: {
general: true,
billing: false,
teams: true,
enterprise: false,
"api-keys": false,
},
}, // enterprise hidden if not cloud, api-keys hidden if not owner
{
pathname: "/environments/env123/settings/api-keys",
role: "admin",
isCloud: true,
expectedVisibility: { general: true, billing: true, teams: true, enterprise: false, "api-keys": false },
}, // api-keys hidden if not owner
{
pathname: "/environments/env123/settings/enterprise",
role: "owner",
isCloud: false,
expectedVisibility: { general: true, billing: false, teams: true, enterprise: true, "api-keys": true },
}, // enterprise shown if not cloud and not member
])(
"renders correct navigation items based on props and path ($pathname, $role, $isCloud)",
({ pathname, role, isCloud, expectedVisibility }) => {
vi.mocked(usePathname).mockReturnValue(pathname);
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: role === "owner",
isMember: role === "member",
} as any);
render(
<OrganizationSettingsNavbar
{...defaultProps}
membershipRole={role as TOrganizationRole}
isFormbricksCloud={isCloud}
/>
);
expect(screen.getByTestId("secondary-navigation")).toBeInTheDocument();
expect(mockSecondaryNavigationProps).not.toBeNull();
const visibleNavItems = mockSecondaryNavigationProps.navigation.filter((item: any) => !item.hidden);
const visibleIds = visibleNavItems.map((item: any) => item.id);
Object.entries(expectedVisibility).forEach(([id, shouldBeVisible]) => {
if (shouldBeVisible) {
expect(visibleIds).toContain(id);
} else {
expect(visibleIds).not.toContain(id);
}
});
// Check current status
mockSecondaryNavigationProps.navigation.forEach((item: any) => {
if (item.href === pathname) {
expect(item.current).toBe(true);
}
});
}
);
test("passes loading prop to SecondaryNavigation", () => {
vi.mocked(usePathname).mockReturnValue("/environments/env123/settings/general");
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: true,
isMember: false,
} as any);
render(<OrganizationSettingsNavbar {...defaultProps} loading={true} />);
expect(mockSecondaryNavigationProps.loading).toBe(true);
});
test("hides billing when loading is true", () => {
vi.mocked(usePathname).mockReturnValue("/environments/env123/settings/general");
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: true,
isMember: false,
} as any);
render(<OrganizationSettingsNavbar {...defaultProps} isFormbricksCloud={true} loading={true} />);
const billingItem = mockSecondaryNavigationProps.navigation.find((item: any) => item.id === "billing");
expect(billingItem.hidden).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false, // Enterprise page is typically for self-hosted
}));
// Mock server-side translation
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
// Mock child components
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="page-content-wrapper">{children}</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, children }: { pageTitle: string; children: React.ReactNode }) => (
<div data-testid="page-header">
<h1>{pageTitle}</h1>
{children}
</div>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar",
() => ({
OrganizationSettingsNavbar: ({ activeId, loading }: { activeId: string; loading?: boolean }) => (
<div data-testid="org-settings-navbar">
Active: {activeId}, Loading: {String(loading)}
</div>
),
})
);
describe("Enterprise Loading Page", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders PageContentWrapper, PageHeader, and OrganizationSettingsNavbar", async () => {
render(await Loading());
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
const pageHeader = screen.getByTestId("page-header");
expect(pageHeader).toBeInTheDocument();
expect(pageHeader).toHaveTextContent("environments.settings.general.organization_settings");
const navbar = screen.getByTestId("org-settings-navbar");
expect(navbar).toBeInTheDocument();
expect(navbar).toHaveTextContent("Active: enterprise");
expect(navbar).toHaveTextContent("Loading: true");
});
test("renders placeholder divs", async () => {
render(await Loading());
const placeholders = screen.getAllByRole("generic", { hidden: true });
const animatedPlaceholders = placeholders.filter((el) => el.classList.contains("animate-pulse"));
expect(animatedPlaceholders.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,193 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import EnterpriseSettingsPage from "./page";
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
findMany: vi.fn(),
},
environment: {
findUnique: vi.fn(),
},
project: {
findFirst: vi.fn(),
},
},
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
usePathname: vi.fn(),
notFound: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="page-content-wrapper">{children}</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="page-header">{children}</div>
),
}));
vi.mock("@/modules/ui/components/settings-card", () => ({
SettingsCard: ({ title, description, children }: any) => (
<div data-testid={`settings-card-${title?.split(".")[0]}`}>
<h2>{title}</h2>
<p>{description}</p>
{children}
</div>
),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", async () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
IS_PRODUCTION: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
SAML_DATABASE_URL: "mock-saml-database-url",
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
E2E_TESTING: "mock-e2e-testing",
}));
const mockEnvironmentId = "c6x2k3vq00000e5twdfh8x9xg";
const mockOrganizationId = "test-org-id";
const mockUserId = "test-user-id";
const mockSession = {
user: {
id: mockUserId,
},
};
const mockUser = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
createdAt: new Date(),
updatedAt: new Date(),
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;
const mockOrganization = {
id: mockOrganizationId,
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
plan: "free",
limits: { monthly: { responses: null, miu: null }, projects: null },
features: {
isUsageBasedSubscriptionEnabled: false,
isSubscriptionUpdateDisabled: false,
},
} as unknown as TOrganizationBilling,
} as unknown as TOrganization;
const mockMembership: TMembership = {
organizationId: mockOrganizationId,
userId: mockUserId,
accepted: true,
role: "owner",
};
describe("EnterpriseSettingsPage", () => {
beforeEach(() => {
vi.resetAllMocks();
mockIsFormbricksCloud = false;
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environmentId: mockEnvironmentId,
organizationId: mockOrganizationId,
userId: mockUserId,
} as any);
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({ isOwner: true, isAdmin: true } as any); // Ensure isAdmin is also covered if relevant
});
afterEach(() => {
cleanup();
});
test("renders correctly for an owner when not on Formbricks Cloud", async () => {
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
expect(redirect).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,192 @@
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { DeleteOrganization } from "./DeleteOrganization";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions", () => ({
deleteOrganizationAction: vi.fn(),
}));
const mockT = (key: string, params?: any) => {
if (params && typeof params === "object") {
let translation = key;
for (const p in params) {
translation = translation.replace(`{{${p}}}`, params[p]);
}
return translation;
}
return key;
};
const organizationMock = {
id: "org_123",
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
plan: "free",
} as unknown as TOrganizationBilling,
} as unknown as TOrganization;
const mockRouterPush = vi.fn();
const renderComponent = (props: Partial<Parameters<typeof DeleteOrganization>[0]> = {}) => {
const defaultProps = {
organization: organizationMock,
isDeleteDisabled: false,
isUserOwner: true,
...props,
};
return render(<DeleteOrganization {...defaultProps} />);
};
describe("DeleteOrganization", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any);
localStorage.clear();
});
afterEach(() => {
cleanup();
});
test("renders delete button and info text when delete is not disabled", () => {
renderComponent();
expect(screen.getByText("environments.settings.general.once_its_gone_its_gone")).toBeInTheDocument();
const deleteButton = screen.getByRole("button", { name: "common.delete" });
expect(deleteButton).toBeInTheDocument();
expect(deleteButton).not.toBeDisabled();
});
test("renders warning and no delete button when delete is disabled and user is owner", () => {
renderComponent({ isDeleteDisabled: true, isUserOwner: true });
expect(
screen.getByText("environments.settings.general.cannot_delete_only_organization")
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.delete" })).not.toBeInTheDocument();
});
test("renders warning and no delete button when delete is disabled and user is not owner", () => {
renderComponent({ isDeleteDisabled: true, isUserOwner: false });
expect(
screen.getByText("environments.settings.general.only_org_owner_can_perform_action")
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.delete" })).not.toBeInTheDocument();
});
test("opens delete dialog on button click", async () => {
renderComponent();
const deleteButton = screen.getByRole("button", { name: "common.delete" });
await userEvent.click(deleteButton);
expect(screen.getByText("environments.settings.general.delete_organization_warning")).toBeInTheDocument();
expect(
screen.getByText(
mockT("environments.settings.general.delete_organization_warning_3", {
organizationName: organizationMock.name,
})
)
).toBeInTheDocument();
});
test("delete button in modal is disabled until correct organization name is typed", async () => {
renderComponent();
const deleteButton = screen.getByRole("button", { name: "common.delete" });
await userEvent.click(deleteButton);
const dialog = screen.getByRole("dialog");
const modalDeleteButton = within(dialog).getByRole("button", { name: "common.delete" });
expect(modalDeleteButton).toBeDisabled();
const inputField = screen.getByPlaceholderText(organizationMock.name);
await userEvent.type(inputField, organizationMock.name);
expect(modalDeleteButton).not.toBeDisabled();
await userEvent.clear(inputField);
await userEvent.type(inputField, "Wrong Name");
expect(modalDeleteButton).toBeDisabled();
});
test("calls deleteOrganizationAction on confirm, shows success, clears localStorage, and navigates", async () => {
vi.mocked(deleteOrganizationAction).mockResolvedValue({} as any);
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, "some-env-id");
renderComponent();
const deleteButton = screen.getByRole("button", { name: "common.delete" });
await userEvent.click(deleteButton);
const inputField = screen.getByPlaceholderText(organizationMock.name);
await userEvent.type(inputField, organizationMock.name);
const dialog = screen.getByRole("dialog");
const modalDeleteButton = within(dialog).getByRole("button", { name: "common.delete" });
await userEvent.click(modalDeleteButton);
await waitFor(() => {
expect(deleteOrganizationAction).toHaveBeenCalledWith({ organizationId: organizationMock.id });
expect(toast.success).toHaveBeenCalledWith(
"environments.settings.general.organization_deleted_successfully"
);
expect(localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS)).toBeNull();
expect(mockRouterPush).toHaveBeenCalledWith("/");
expect(
screen.queryByText("environments.settings.general.delete_organization_warning")
).not.toBeInTheDocument(); // Modal should close
});
});
test("shows error toast on deleteOrganizationAction failure", async () => {
vi.mocked(deleteOrganizationAction).mockRejectedValue(new Error("Deletion failed"));
renderComponent();
const deleteButton = screen.getByRole("button", { name: "common.delete" });
await userEvent.click(deleteButton);
const inputField = screen.getByPlaceholderText(organizationMock.name);
await userEvent.type(inputField, organizationMock.name);
const dialog = screen.getByRole("dialog");
const modalDeleteButton = within(dialog).getByRole("button", { name: "common.delete" });
await userEvent.click(modalDeleteButton);
await waitFor(() => {
expect(deleteOrganizationAction).toHaveBeenCalledWith({ organizationId: organizationMock.id });
expect(toast.error).toHaveBeenCalledWith(
"environments.settings.general.error_deleting_organization_please_try_again"
);
expect(
screen.queryByText("environments.settings.general.delete_organization_warning")
).not.toBeInTheDocument(); // Modal should close
});
});
test("closes modal on cancel click", async () => {
renderComponent();
const deleteButton = screen.getByRole("button", { name: "common.delete" });
await userEvent.click(deleteButton);
expect(screen.getByText("environments.settings.general.delete_organization_warning")).toBeInTheDocument();
const cancelButton = screen.getByRole("button", { name: "common.cancel" });
await userEvent.click(cancelButton);
await waitFor(() => {
expect(
screen.queryByText("environments.settings.general.delete_organization_warning")
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,149 @@
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { EditOrganizationNameForm } from "./EditOrganizationNameForm";
vi.mock("@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions", () => ({
updateOrganizationNameAction: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
const organizationMock = {
id: "org_123",
name: "Old Organization Name",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
plan: "free",
} as unknown as TOrganization["billing"],
} as unknown as TOrganization;
const renderForm = (membershipRole: "owner" | "member") => {
return render(
<EditOrganizationNameForm
environmentId="env_123"
organization={organizationMock}
membershipRole={membershipRole}
/>
);
};
describe("EditOrganizationNameForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(updateOrganizationNameAction).mockReset();
});
test("renders with initial organization name and allows owner to update", async () => {
renderForm("owner");
const nameInput = screen.getByPlaceholderText(
"environments.settings.general.organization_name_placeholder"
);
expect(nameInput).toHaveValue(organizationMock.name);
expect(nameInput).not.toBeDisabled();
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled(); // Initially disabled as form is not dirty
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "New Organization Name");
expect(updateButton).not.toBeDisabled(); // Enabled after change
vi.mocked(updateOrganizationNameAction).mockResolvedValueOnce({
data: { ...organizationMock, name: "New Organization Name" },
});
await userEvent.click(updateButton);
await waitFor(() => {
expect(updateOrganizationNameAction).toHaveBeenCalledWith({
organizationId: organizationMock.id,
data: { name: "New Organization Name" },
});
expect(
screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder")
).toHaveValue("New Organization Name");
expect(toast.success).toHaveBeenCalledWith(
"environments.settings.general.organization_name_updated_successfully"
);
});
expect(updateButton).toBeDisabled(); // Disabled after successful submit and reset
});
test("shows error toast on update failure", async () => {
renderForm("owner");
const nameInput = screen.getByPlaceholderText(
"environments.settings.general.organization_name_placeholder"
);
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Another Name");
const updateButton = screen.getByText("common.update");
vi.mocked(updateOrganizationNameAction).mockResolvedValueOnce({
data: null as any,
});
await userEvent.click(updateButton);
await waitFor(() => {
expect(updateOrganizationNameAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("");
});
expect(nameInput).toHaveValue("Another Name"); // Name should not reset on error
});
test("shows generic error toast on exception during update", async () => {
renderForm("owner");
const nameInput = screen.getByPlaceholderText(
"environments.settings.general.organization_name_placeholder"
);
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Exception Name");
const updateButton = screen.getByText("common.update");
vi.mocked(updateOrganizationNameAction).mockRejectedValueOnce(new Error("Network error"));
await userEvent.click(updateButton);
await waitFor(() => {
expect(updateOrganizationNameAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("Error: Network error");
});
});
test("disables input and button for non-owner roles and shows warning", async () => {
const roles: "member"[] = ["member"];
for (const role of roles) {
renderForm(role);
const nameInput = screen.getByPlaceholderText(
"environments.settings.general.organization_name_placeholder"
);
expect(nameInput).toBeDisabled();
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled();
expect(
screen.getByText("environments.settings.general.only_org_owner_can_perform_action")
).toBeInTheDocument();
cleanup();
}
});
});

View File

@@ -0,0 +1,67 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar",
() => ({
OrganizationSettingsNavbar: vi.fn(() => <div>OrganizationSettingsNavbar</div>),
})
);
vi.mock("@/app/(app)/components/LoadingCard", () => ({
LoadingCard: vi.fn(({ title, description }) => (
<div>
<div>{title}</div>
<div>{description}</div>
</div>
)),
}));
describe("Loading", () => {
const mockTranslate = vi.fn((key) => key);
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
});
test("renders loading state correctly", async () => {
const LoadingComponent = await Loading();
render(LoadingComponent);
expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument();
expect(OrganizationSettingsNavbar).toHaveBeenCalledWith(
{
isFormbricksCloud: IS_FORMBRICKS_CLOUD,
activeId: "general",
loading: true,
},
undefined
);
expect(screen.getByText("environments.settings.general.organization_name")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.general.organization_name_description")
).toBeInTheDocument();
expect(screen.getByText("environments.settings.general.delete_organization")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.general.delete_organization_description")
).toBeInTheDocument();
});
});

View File

@@ -1,10 +1,17 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import Page from "./page";
vi.mock("@/lib/constants", () => ({
@@ -52,7 +59,34 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getWhiteLabelPermission: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar",
() => ({
OrganizationSettingsNavbar: vi.fn(() => <div>OrganizationSettingsNavbar</div>),
})
);
vi.mock("./components/EditOrganizationNameForm", () => ({
EditOrganizationNameForm: vi.fn(() => <div>EditOrganizationNameForm</div>),
}));
vi.mock("@/modules/ee/whitelabel/email-customization/components/email-customization-settings", () => ({
EmailCustomizationSettings: vi.fn(() => <div>EmailCustomizationSettings</div>),
}));
vi.mock("./components/DeleteOrganization", () => ({
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div>SettingsId</div>),
}));
describe("Page", () => {
afterEach(() => {
cleanup();
});
let mockEnvironmentAuth = {
session: { user: { id: "test-user-id" } },
currentUserMembership: { role: "owner" },
@@ -63,8 +97,10 @@ describe("Page", () => {
const mockUser = { id: "test-user-id" } as TUser;
const mockTranslate = vi.fn((key) => key);
const mockParams = { environmentId: "env-123" };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
@@ -72,28 +108,163 @@ describe("Page", () => {
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
});
test("renders the page with organization settings", async () => {
test("renders the page with organization settings for owner", async () => {
const props = {
params: Promise.resolve({ environmentId: "env-123" }),
params: Promise.resolve(mockParams),
};
const result = await Page(props);
const PageComponent = await Page(props);
render(PageComponent);
expect(result).toBeTruthy();
expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument();
expect(OrganizationSettingsNavbar).toHaveBeenCalledWith(
{
environmentId: mockParams.environmentId,
isFormbricksCloud: IS_FORMBRICKS_CLOUD,
membershipRole: "owner",
activeId: "general",
},
undefined
);
expect(screen.getByText("environments.settings.general.organization_name")).toBeInTheDocument();
expect(EditOrganizationNameForm).toHaveBeenCalledWith(
{
organization: mockEnvironmentAuth.organization,
environmentId: mockParams.environmentId,
membershipRole: "owner",
},
undefined
);
expect(EmailCustomizationSettings).toHaveBeenCalledWith(
{
organization: mockEnvironmentAuth.organization,
hasWhiteLabelPermission: true,
environmentId: mockParams.environmentId,
isReadOnly: false,
isFormbricksCloud: IS_FORMBRICKS_CLOUD,
fbLogoUrl: FB_LOGO_URL,
user: mockUser,
},
undefined
);
expect(screen.getByText("environments.settings.general.delete_organization")).toBeInTheDocument();
expect(DeleteOrganization).toHaveBeenCalledWith(
{
organization: mockEnvironmentAuth.organization,
isDeleteDisabled: false,
isUserOwner: true,
},
undefined
);
expect(SettingsId).toHaveBeenCalledWith(
{
title: "common.organization_id",
id: mockEnvironmentAuth.organization.id,
},
undefined
);
});
test("renders if session user id empty", async () => {
mockEnvironmentAuth.session.user.id = "";
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
test("renders correctly when user is manager", async () => {
const managerAuth = {
...mockEnvironmentAuth,
currentUserMembership: { role: "manager" },
isOwner: false,
isManager: true,
} as unknown as TEnvironmentAuth;
vi.mocked(getEnvironmentAuth).mockResolvedValue(managerAuth);
const props = {
params: Promise.resolve({ environmentId: "env-123" }),
params: Promise.resolve(mockParams),
};
const PageComponent = await Page(props);
render(PageComponent);
expect(EmailCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
isReadOnly: false, // owner or manager can edit
}),
undefined
);
expect(DeleteOrganization).toHaveBeenCalledWith(
expect.objectContaining({
isDeleteDisabled: true, // only owner can delete
isUserOwner: false,
}),
undefined
);
});
test("renders correctly when multi-org is disabled", async () => {
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
const props = {
params: Promise.resolve(mockParams),
};
const PageComponent = await Page(props);
render(PageComponent);
expect(screen.queryByText("environments.settings.general.delete_organization")).not.toBeInTheDocument();
expect(DeleteOrganization).not.toHaveBeenCalled();
// isDeleteDisabled should be true because multiOrg is disabled, even for owner
expect(EmailCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
isReadOnly: false,
}),
undefined
);
});
test("renders correctly when user is not owner or manager (e.g., admin)", async () => {
const adminAuth = {
...mockEnvironmentAuth,
currentUserMembership: { role: "admin" },
isOwner: false,
isManager: false,
} as unknown as TEnvironmentAuth;
vi.mocked(getEnvironmentAuth).mockResolvedValue(adminAuth);
const props = {
params: Promise.resolve(mockParams),
};
const PageComponent = await Page(props);
render(PageComponent);
expect(EmailCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
isReadOnly: true,
}),
undefined
);
expect(DeleteOrganization).toHaveBeenCalledWith(
expect.objectContaining({
isDeleteDisabled: true,
isUserOwner: false,
}),
undefined
);
});
test("renders if session user id empty, user is null", async () => {
const noUserSessionAuth = {
...mockEnvironmentAuth,
session: { ...mockEnvironmentAuth.session, user: { ...mockEnvironmentAuth.session.user, id: "" } },
};
vi.mocked(getEnvironmentAuth).mockResolvedValue(noUserSessionAuth);
vi.mocked(getUser).mockResolvedValue(null);
const props = {
params: Promise.resolve(mockParams),
};
const result = await Page(props);
expect(result).toBeTruthy();
const PageComponent = await Page(props);
render(PageComponent);
expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument();
expect(EmailCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
user: null,
}),
undefined
);
});
test("handles getEnvironmentAuth error", async () => {

View File

@@ -0,0 +1,98 @@
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { cleanup, render, screen } from "@testing-library/react";
import { Session, getServerSession } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import OrganizationSettingsLayout from "./layout";
// Mock dependencies
vi.mock("@/lib/organization/service");
vi.mock("@/lib/project/service");
vi.mock("next-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("next-auth")>();
return {
...actual,
getServerSession: vi.fn(),
};
});
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {}, // Mock authOptions if it's directly used or causes issues
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);
const mockGetProjectByEnvironmentId = vi.mocked(getProjectByEnvironmentId);
const mockGetServerSession = vi.mocked(getServerSession);
const mockOrganization = { id: "org_test_id" } as unknown as TOrganization;
const mockProject = { id: "project_test_id" } as unknown as TProject;
const mockSession = { user: { id: "user_test_id" } } as unknown as Session;
const t = (key: string) => key;
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => t,
}));
const mockProps = {
params: { environmentId: "env_test_id" },
children: <div>Child Content for Organization Settings</div>,
};
describe("OrganizationSettingsLayout", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.resetAllMocks();
mockGetOrganizationByEnvironmentId.mockResolvedValue(mockOrganization);
mockGetProjectByEnvironmentId.mockResolvedValue(mockProject);
mockGetServerSession.mockResolvedValue(mockSession);
});
test("should render children when all data is fetched successfully", async () => {
render(await OrganizationSettingsLayout(mockProps));
expect(screen.getByText("Child Content for Organization Settings")).toBeInTheDocument();
});
test("should throw error if organization is not found", async () => {
mockGetOrganizationByEnvironmentId.mockResolvedValue(null);
await expect(OrganizationSettingsLayout(mockProps)).rejects.toThrowError("common.organization_not_found");
});
test("should throw error if project is not found", async () => {
mockGetProjectByEnvironmentId.mockResolvedValue(null);
await expect(OrganizationSettingsLayout(mockProps)).rejects.toThrowError("common.project_not_found");
});
test("should throw error if session is not found", async () => {
mockGetServerSession.mockResolvedValue(null);
await expect(OrganizationSettingsLayout(mockProps)).rejects.toThrowError("common.session_not_found");
});
});

View File

@@ -0,0 +1,38 @@
import { TeamsPage } from "@/modules/organization/settings/teams/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
FB_LOGO_URL: "mock-fb-logo-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: 587,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
}));
describe("TeamsPage re-export", () => {
test("should re-export TeamsPage component", () => {
expect(Page).toBe(TeamsPage);
});
});

View File

@@ -0,0 +1,72 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@/modules/ui/components/badge", () => ({
Badge: ({ text }) => <div data-testid="mock-badge">{text}</div>,
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key) => key, // Mock t function to return the key
}),
}));
describe("SettingsCard", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
title: "Test Title",
description: "Test Description",
children: <div data-testid="child-content">Child Content</div>,
};
test("renders title, description, and children", () => {
render(<SettingsCard {...defaultProps} />);
expect(screen.getByText(defaultProps.title)).toBeInTheDocument();
expect(screen.getByText(defaultProps.description)).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toBeInTheDocument();
});
test("renders Beta badge when beta prop is true", () => {
render(<SettingsCard {...defaultProps} beta />);
const badgeElement = screen.getByTestId("mock-badge");
expect(badgeElement).toBeInTheDocument();
expect(badgeElement).toHaveTextContent("Beta");
});
test("renders Soon badge when soon prop is true", () => {
render(<SettingsCard {...defaultProps} soon />);
const badgeElement = screen.getByTestId("mock-badge");
expect(badgeElement).toBeInTheDocument();
expect(badgeElement).toHaveTextContent("environments.settings.enterprise.coming_soon");
});
test("does not render badges when beta and soon props are false", () => {
render(<SettingsCard {...defaultProps} />);
expect(screen.queryByTestId("mock-badge")).not.toBeInTheDocument();
});
test("applies default padding when noPadding prop is false", () => {
render(<SettingsCard {...defaultProps} />);
const childrenContainer = screen.getByTestId("child-content").parentElement;
expect(childrenContainer).toHaveClass("px-4 pt-4");
});
test("applies custom className to the root element", () => {
const customClass = "my-custom-class";
render(<SettingsCard {...defaultProps} className={customClass} />);
const cardElement = screen.getByText(defaultProps.title).closest("div.relative");
expect(cardElement).toHaveClass(customClass);
});
test("renders with default classes", () => {
render(<SettingsCard {...defaultProps} />);
const cardElement = screen.getByText(defaultProps.title).closest("div.relative");
expect(cardElement).toHaveClass(
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-sm"
);
});
});

View File

@@ -0,0 +1,25 @@
import { SettingsTitle } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsTitle";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
describe("SettingsTitle", () => {
afterEach(() => {
cleanup();
});
test("renders the title correctly", () => {
const titleText = "My Awesome Settings";
render(<SettingsTitle title={titleText} />);
const headingElement = screen.getByRole("heading", { name: titleText, level: 2 });
expect(headingElement).toBeInTheDocument();
expect(headingElement).toHaveTextContent(titleText);
expect(headingElement).toHaveClass("my-4 text-2xl font-medium leading-6 text-slate-800");
});
test("renders with an empty title", () => {
render(<SettingsTitle title="" />);
const headingElement = screen.getByRole("heading", { level: 2 });
expect(headingElement).toBeInTheDocument();
expect(headingElement).toHaveTextContent("");
});
});

View File

@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("Settings Page", () => {
test("should redirect to profile settings page", async () => {
const params = { environmentId: "testEnvId" };
await Page({ params });
expect(redirect).toHaveBeenCalledWith(`/environments/${params.environmentId}/settings/profile`);
});
});

View File

@@ -0,0 +1,37 @@
import { cleanup, render, screen } from "@testing-library/react";
import { Unplug } from "lucide-react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { EmptyAppSurveys } from "./EmptyInAppSurveys";
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
Unplug: vi.fn(() => <div data-testid="unplug-icon" />),
};
});
const mockEnvironment = {
id: "test-env-id",
} as unknown as TEnvironment;
describe("EmptyAppSurveys", () => {
afterEach(() => {
cleanup();
});
test("renders correctly with translated text and icon", () => {
render(<EmptyAppSurveys environment={mockEnvironment} />);
expect(screen.getByTestId("unplug-icon")).toBeInTheDocument();
expect(Unplug).toHaveBeenCalled();
expect(screen.getByText("environments.surveys.summary.youre_not_plugged_in_yet")).toBeInTheDocument();
expect(
screen.getByText(
"environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started"
)
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,243 @@
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
revalidateSurveyIdPath,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { useIntervalWhenFocused } from "@/lib/utils/hooks/useIntervalWhenFocused";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
FB_LOGO_URL: "mock-fb-logo-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: 587,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
vi.mock("@/app/lib/surveys/surveys");
vi.mock("@/app/share/[sharingKey]/actions");
vi.mock("@/lib/utils/hooks/useIntervalWhenFocused");
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
}));
vi.mock("next/navigation", () => ({
usePathname: vi.fn(),
useParams: vi.fn(),
useSearchParams: vi.fn(),
}));
const mockUsePathname = vi.mocked(usePathname);
const mockUseParams = vi.mocked(useParams);
const mockUseSearchParams = vi.mocked(useSearchParams);
const mockUseResponseFilter = vi.mocked(useResponseFilter);
const mockGetResponseCountAction = vi.mocked(getResponseCountAction);
const mockRevalidateSurveyIdPath = vi.mocked(revalidateSurveyIdPath);
const mockGetFormattedFilters = vi.mocked(getFormattedFilters);
const mockUseIntervalWhenFocused = vi.mocked(useIntervalWhenFocused);
const MockSecondaryNavigation = vi.mocked(SecondaryNavigation);
const mockSurveyLanguages: TSurveyLanguage[] = [
{ language: { code: "en-US" } as unknown as TLanguage, default: true, enabled: true },
];
const mockSurvey = {
id: "surveyId123",
name: "Test Survey",
type: "app",
environmentId: "envId123",
status: "inProgress",
questions: [
{
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: false,
logic: [],
isDraft: false,
imageUrl: "",
subheader: { default: "" },
} as unknown as TSurveyQuestion,
],
hiddenFields: { enabled: false, fieldIds: [] },
displayOption: "displayOnce",
autoClose: null,
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
languages: mockSurveyLanguages,
variables: [],
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
segment: null,
resultShareKey: null,
closeOnDate: null,
delay: 0,
autoComplete: null,
recontactDays: null,
runOnDate: null,
displayPercentage: null,
createdBy: null,
} as unknown as TSurvey;
const defaultProps = {
environmentId: "testEnvId",
survey: mockSurvey,
initialTotalResponseCount: 10,
activeId: "summary",
};
describe("SurveyAnalysisNavigation", () => {
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("calls revalidateSurveyIdPath on navigation item click", async () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
render(<SurveyAnalysisNavigation {...defaultProps} />);
await waitFor(() => expect(MockSecondaryNavigation).toHaveBeenCalled());
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
if (!lastCallArgs.navigation || lastCallArgs.navigation.length < 2) {
throw new Error("Navigation items not found");
}
act(() => {
(lastCallArgs.navigation[0] as any).onClick();
});
expect(mockRevalidateSurveyIdPath).toHaveBeenCalledWith(
defaultProps.environmentId,
defaultProps.survey.id
);
vi.mocked(mockRevalidateSurveyIdPath).mockClear();
act(() => {
(lastCallArgs.navigation[1] as any).onClick();
});
expect(mockRevalidateSurveyIdPath).toHaveBeenCalledWith(
defaultProps.environmentId,
defaultProps.survey.id
);
});
test("passes correct runWhen flag to useIntervalWhenFocused based on share embed modal", () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
);
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue("true") } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, false, false);
cleanup();
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
render(<SurveyAnalysisNavigation {...defaultProps} />);
expect(mockUseIntervalWhenFocused).toHaveBeenCalledWith(expect.any(Function), 10000, true, false);
});
test("displays correct response count string in label for various scenarios", async () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
);
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
// Scenario 1: total = 10, filtered = null (initial state)
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
expect(MockSecondaryNavigation.mock.calls[0][0].navigation[1].label).toBe("common.responses (10)");
cleanup();
vi.resetAllMocks(); // Reset mocks for next case
// Scenario 2: total = 15, filtered = 15
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
);
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockImplementation(async (args) => {
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
return { data: 15, error: null, success: true };
});
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={15} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
});
cleanup();
vi.resetAllMocks();
// Scenario 3: total = 10, filtered = 15 (filtered > total)
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
);
mockUseParams.mockReturnValue({ environmentId: defaultProps.environmentId, surveyId: mockSurvey.id });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
mockGetFormattedFilters.mockReturnValue([] as any);
mockGetResponseCountAction.mockImplementation(async (args) => {
if (args && "filterCriteria" in args) return { data: 15, error: null, success: true };
return { data: 10, error: null, success: true };
});
render(<SurveyAnalysisNavigation {...defaultProps} initialTotalResponseCount={10} />);
await waitFor(() => {
const lastCallArgs =
MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
expect(lastCallArgs.navigation[1].label).toBe("common.responses (15)");
});
});
});

View File

@@ -0,0 +1,124 @@
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import SurveyLayout, { generateMetadata } from "./layout";
vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
const mockSurveyId = "survey_123";
const mockEnvironmentId = "env_456";
const mockSurveyName = "Test Survey";
const mockResponseCount = 10;
const mockSurvey = {
id: mockSurveyId,
name: mockSurveyName,
questions: [],
endings: [],
status: "inProgress",
type: "app",
environmentId: mockEnvironmentId,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
variables: [],
triggers: [],
styling: null,
languages: [],
segment: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayLimit: null,
displayOption: "displayOnce",
isBackButtonHidden: false,
pin: null,
recontactDays: null,
resultShareKey: null,
runOnDate: null,
showLanguageSwitch: false,
singleUse: null,
surveyClosedMessage: null,
createdAt: new Date(),
updatedAt: new Date(),
autoComplete: null,
hiddenFields: { enabled: false, fieldIds: [] },
} as unknown as TSurvey;
describe("SurveyLayout", () => {
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
describe("generateMetadata", () => {
test("should return correct metadata when session and survey exist", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user_test_id" } });
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponseCount);
const metadata = await generateMetadata({
params: Promise.resolve({ surveyId: mockSurveyId, environmentId: mockEnvironmentId }),
});
expect(metadata).toEqual({
title: `${mockResponseCount} Responses | ${mockSurveyName} Results`,
});
expect(getServerSession).toHaveBeenCalledWith(authOptions);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId);
});
test("should return correct metadata when survey is null", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user_test_id" } });
vi.mocked(getSurvey).mockResolvedValue(null);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponseCount);
const metadata = await generateMetadata({
params: Promise.resolve({ surveyId: mockSurveyId, environmentId: mockEnvironmentId }),
});
expect(metadata).toEqual({
title: `${mockResponseCount} Responses | undefined Results`,
});
});
test("should return empty title when session does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponseCount);
const metadata = await generateMetadata({
params: Promise.resolve({ surveyId: mockSurveyId, environmentId: mockEnvironmentId }),
});
expect(metadata).toEqual({
title: "",
});
});
});
describe("SurveyLayout Component", () => {
test("should render children", async () => {
const childText = "Test Child Component";
render(await SurveyLayout({ children: <div>{childText}</div> }));
expect(screen.getByText(childText)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,249 @@
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
vi.mock("@/modules/analysis/components/SingleResponseCard", () => ({
SingleResponseCard: vi.fn(() => <div data-testid="single-response-card">SingleResponseCard</div>),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, onClick, disabled, variant, className }) => (
<button
onClick={onClick}
disabled={disabled}
data-variant={variant}
className={className}
data-testid="mock-button">
{children}
</button>
)),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
}));
const mockResponses = [
{
id: "response1",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: true,
data: {},
meta: {
userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" },
url: "http://localhost:3000",
},
notes: [],
tags: [],
} as unknown as TResponse,
{
id: "response2",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: true,
data: {},
meta: {
userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" },
url: "http://localhost:3000/page2",
},
notes: [],
tags: [],
} as unknown as TResponse,
{
id: "response3",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: false,
data: {},
meta: {
userAgent: { browser: "Safari", os: "iOS", device: "Mobile" },
url: "http://localhost:3000/page3",
},
notes: [],
tags: [],
} as unknown as TResponse,
] as unknown as TResponse[];
const mockSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: "env1",
status: "inProgress",
questions: [],
hiddenFields: { enabled: false, fieldIds: [] },
displayOption: "displayOnce",
recontactDays: 0,
autoClose: null,
closeOnDate: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
triggers: [],
languages: [],
resultShareKey: null,
displayPercentage: null,
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
styling: null,
} as unknown as TSurvey;
const mockEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "increase_conversion",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [
{ id: "tag1", createdAt: new Date(), updatedAt: new Date(), name: "Tag 1", environmentId: "env1" },
];
const mockLocale: TUserLocale = "en-US";
const mockSetSelectedResponseId = vi.fn();
const mockUpdateResponse = vi.fn();
const mockDeleteResponses = vi.fn();
const mockSetOpen = vi.fn();
const defaultProps = {
responses: mockResponses,
selectedResponseId: mockResponses[0].id,
setSelectedResponseId: mockSetSelectedResponseId,
survey: mockSurvey,
environment: mockEnvironment,
user: mockUser,
environmentTags: mockEnvironmentTags,
updateResponse: mockUpdateResponse,
deleteResponses: mockDeleteResponses,
isReadOnly: false,
open: true,
setOpen: mockSetOpen,
locale: mockLocale,
};
describe("ResponseCardModal", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
});
test("should not render if selectedResponseId is null", () => {
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("should render the modal when a response is selected", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
});
test("should call setSelectedResponseId with the next response id when next button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[0].id} />);
const buttons = screen.getAllByTestId("mock-button");
const nextButton = buttons.find((button) => button.querySelector("svg.lucide-chevron-right"));
if (nextButton) await userEvent.click(nextButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(mockResponses[1].id);
});
test("should call setSelectedResponseId with the previous response id when back button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
const buttons = screen.getAllByTestId("mock-button");
const backButton = buttons.find((button) => button.querySelector("svg.lucide-chevron-left"));
if (backButton) await userEvent.click(backButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(mockResponses[0].id);
});
test("should disable back button if current response is the first one", () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[0].id} />);
const buttons = screen.getAllByTestId("mock-button");
const backButton = buttons.find((button) => button.querySelector("svg.lucide-chevron-left"));
expect(backButton).toBeDisabled();
});
test("should disable next button if current response is the last one", () => {
render(
<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[mockResponses.length - 1].id} />
);
const buttons = screen.getAllByTestId("mock-button");
const nextButton = buttons.find((button) => button.querySelector("svg.lucide-chevron-right"));
expect(nextButton).toBeDisabled();
});
test("should call setSelectedResponseId with null when close button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} />);
const buttons = screen.getAllByTestId("mock-button");
const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x"));
if (closeButton) await userEvent.click(closeButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null);
});
test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
expect(mockSetOpen).toHaveBeenCalledWith(true);
// Current index is internal state, but we can check if the correct response is displayed
// by checking the props passed to SingleResponseCard
expect(vi.mocked(SingleResponseCard).mock.calls[0][0].response).toEqual(mockResponses[1]);
});
test("useEffect should set open to false when selectedResponseId is null after being open", () => {
const { rerender } = render(
<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[0].id} />
);
expect(mockSetOpen).toHaveBeenCalledWith(true);
rerender(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("should render ChevronLeft, ChevronRight, and XIcon", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument();
expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument();
expect(document.querySelector(".lucide-x")).toBeInTheDocument();
});
});
// Mock Lucide icons for easier querying
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
ChevronLeft: vi.fn((props) => <svg {...props} className="lucide-chevron-left" />),
ChevronRight: vi.fn((props) => <svg {...props} className="lucide-chevron-right" />),
XIcon: vi.fn((props) => <svg {...props} className="lucide-x" />),
};
});

View File

@@ -0,0 +1,388 @@
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import {
ResponseDataView,
extractResponseData,
formatAddressData,
formatContactInfoData,
mapResponsesToTableData,
} from "./ResponseDataView";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable",
() => ({
ResponseTable: vi.fn(() => <div data-testid="response-table">ResponseTable</div>),
})
);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: vi.fn((key) => {
if (key === "environments.surveys.responses.completed") return "Completed";
if (key === "environments.surveys.responses.not_completed") return "Not Completed";
return key;
}),
}),
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2" },
required: false,
choices: [{ id: "c1", label: { default: "Choice 1" } }],
},
{
id: "matrix1",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: false,
rows: [{ id: "row1", label: "Row 1" }],
columns: [{ id: "col1", label: "Col 1" }],
} as unknown as TSurveyQuestion,
{
id: "address1",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Address Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "contactInfo1",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
],
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
triggers: [],
languages: [],
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;
const mockResponses: TResponse[] = [
{
id: "response1",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: true,
data: {
q1: "Answer 1",
q2: "Choice 1",
matrix1: { row1: "Col 1" },
address1: ["123 Main St", "Apt 4B", "Anytown", "CA", "90210", "USA"] as TResponseDataValue,
contactInfo1: [
"John",
"Doe",
"john.doe@example.com",
"555-1234",
"Formbricks Inc.",
] as TResponseDataValue,
hidden1: "Hidden Value 1",
verifiedEmail: "test@example.com",
},
meta: { userAgent: { browser: "test-agent" }, url: "http://localhost" },
singleUseId: null,
ttc: {},
tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }],
notes: [
{
id: "note1",
text: "Note 1",
createdAt: new Date(),
updatedAt: new Date(),
isResolved: false,
isEdited: false,
user: { id: "user1", name: "User 1" },
},
],
variables: { var1: "Response Var Value" },
language: "en",
contact: null,
contactAttributes: null,
},
{
id: "response2",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: false,
data: { q1: "Answer 2" },
meta: { userAgent: { browser: "test-agent-2" }, url: "http://localhost" },
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "de",
contact: null,
contactAttributes: null,
},
];
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
} as unknown as TUser;
const mockEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
} as unknown as TEnvironment;
const mockEnvironmentTags: TTag[] = [
{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
{ id: "tag2", name: "Tag2", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
];
const mockLocale: TUserLocale = "en-US";
const defaultProps = {
survey: mockSurvey,
responses: mockResponses,
user: mockUser,
environment: mockEnvironment,
environmentTags: mockEnvironmentTags,
isReadOnly: false,
fetchNextPage: vi.fn(),
hasMore: true,
deleteResponses: vi.fn(),
updateResponse: vi.fn(),
isFetchingFirstPage: false,
locale: mockLocale,
};
describe("ResponseDataView", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
});
test("renders ResponseTable with correct props", () => {
render(<ResponseDataView {...defaultProps} />);
expect(screen.getByTestId("response-table")).toBeInTheDocument();
const responseTableMock = vi.mocked(ResponseTable);
expect(responseTableMock).toHaveBeenCalledTimes(1);
const expectedData = [
{
responseData: {
q1: "Answer 1",
q2: "Choice 1",
row1: "Col 1", // from matrix question
addressLine1: "123 Main St",
addressLine2: "Apt 4B",
city: "Anytown",
state: "CA",
zip: "90210",
country: "USA",
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
phone: "555-1234",
company: "Formbricks Inc.",
hidden1: "Hidden Value 1",
},
createdAt: mockResponses[0].createdAt,
status: "Completed",
responseId: "response1",
tags: mockResponses[0].tags,
notes: mockResponses[0].notes,
variables: { var1: "Response Var Value" },
verifiedEmail: "test@example.com",
language: "en",
person: null,
contactAttributes: null,
},
{
responseData: {
q1: "Answer 2",
},
createdAt: mockResponses[1].createdAt,
status: "Not Completed",
responseId: "response2",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "de",
person: null,
contactAttributes: null,
},
];
expect(responseTableMock.mock.calls[0][0].data).toEqual(expectedData);
expect(responseTableMock.mock.calls[0][0].survey).toEqual(mockSurvey);
expect(responseTableMock.mock.calls[0][0].responses).toEqual(mockResponses);
expect(responseTableMock.mock.calls[0][0].user).toEqual(mockUser);
expect(responseTableMock.mock.calls[0][0].environmentTags).toEqual(mockEnvironmentTags);
expect(responseTableMock.mock.calls[0][0].isReadOnly).toBe(false);
expect(responseTableMock.mock.calls[0][0].environment).toEqual(mockEnvironment);
expect(responseTableMock.mock.calls[0][0].fetchNextPage).toBe(defaultProps.fetchNextPage);
expect(responseTableMock.mock.calls[0][0].hasMore).toBe(true);
expect(responseTableMock.mock.calls[0][0].deleteResponses).toBe(defaultProps.deleteResponses);
expect(responseTableMock.mock.calls[0][0].updateResponse).toBe(defaultProps.updateResponse);
expect(responseTableMock.mock.calls[0][0].isFetchingFirstPage).toBe(false);
expect(responseTableMock.mock.calls[0][0].locale).toBe(mockLocale);
});
test("formatAddressData correctly formats data", () => {
const addressData: TResponseDataValue = ["1 Main St", "Apt 1", "CityA", "StateA", "10001", "CountryA"];
const formatted = formatAddressData(addressData);
expect(formatted).toEqual({
addressLine1: "1 Main St",
addressLine2: "Apt 1",
city: "CityA",
state: "StateA",
zip: "10001",
country: "CountryA",
});
});
test("formatAddressData handles undefined values", () => {
const addressData: TResponseDataValue = ["1 Main St", "", "CityA", "", "10001", ""]; // Changed undefined to empty string as per function logic
const formatted = formatAddressData(addressData);
expect(formatted).toEqual({
addressLine1: "1 Main St",
addressLine2: "",
city: "CityA",
state: "",
zip: "10001",
country: "",
});
});
test("formatAddressData returns empty object for non-array input", () => {
const formatted = formatAddressData("not an array");
expect(formatted).toEqual({});
});
test("formatContactInfoData correctly formats data", () => {
const contactData: TResponseDataValue = ["Jane", "Doe", "jane@mail.com", "123-456", "Org B"];
const formatted = formatContactInfoData(contactData);
expect(formatted).toEqual({
firstName: "Jane",
lastName: "Doe",
email: "jane@mail.com",
phone: "123-456",
company: "Org B",
});
});
test("formatContactInfoData handles undefined values", () => {
const contactData: TResponseDataValue = ["Jane", "", "jane@mail.com", "", "Org B"]; // Changed undefined to empty string
const formatted = formatContactInfoData(contactData);
expect(formatted).toEqual({
firstName: "Jane",
lastName: "",
email: "jane@mail.com",
phone: "",
company: "Org B",
});
});
test("formatContactInfoData returns empty object for non-array input", () => {
const formatted = formatContactInfoData({});
expect(formatted).toEqual({});
});
test("extractResponseData correctly extracts and formats data", () => {
const response = mockResponses[0];
const survey = mockSurvey;
const extracted = extractResponseData(response, survey);
expect(extracted).toEqual({
q1: "Answer 1",
q2: "Choice 1",
row1: "Col 1", // from matrix question
addressLine1: "123 Main St",
addressLine2: "Apt 4B",
city: "Anytown",
state: "CA",
zip: "90210",
country: "USA",
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
phone: "555-1234",
company: "Formbricks Inc.",
hidden1: "Hidden Value 1",
});
});
test("extractResponseData handles missing optional data", () => {
const response: TResponse = {
...mockResponses[1],
data: { q1: "Answer 2" },
};
const survey = mockSurvey;
const extracted = extractResponseData(response, survey);
expect(extracted).toEqual({
q1: "Answer 2",
// address and contactInfo will add empty strings if the keys exist but values are not arrays
// but here, the keys 'address1' and 'contactInfo1' are not in response.data
// hidden1 is also not in response.data
});
});
test("mapResponsesToTableData correctly maps responses", () => {
const tMock = vi.fn((key) => (key === "environments.surveys.responses.completed" ? "Done" : "Pending"));
const tableData = mapResponsesToTableData(mockResponses, mockSurvey, tMock);
expect(tableData.length).toBe(2);
expect(tableData[0].status).toBe("Done");
expect(tableData[1].status).toBe("Pending");
expect(tableData[0].responseData.q1).toBe("Answer 1");
expect(tableData[0].responseData.hidden1).toBe("Hidden Value 1");
expect(tableData[0].variables.var1).toBe("Response Var Value");
expect(tableData[1].responseData.q1).toBe("Answer 2");
expect(tableData[0].verifiedEmail).toBe("test@example.com");
expect(tableData[1].verifiedEmail).toBe("");
});
});

View File

@@ -24,7 +24,8 @@ interface ResponseDataViewProps {
locale: TUserLocale;
}
const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
// Export for testing
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
return Array.isArray(responseValue)
? responseValue.reduce((acc, curr, index) => {
@@ -34,7 +35,8 @@ const formatAddressData = (responseValue: TResponseDataValue): Record<string, st
: {};
};
const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
// Export for testing
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
return Array.isArray(responseValue)
? responseValue.reduce((acc, curr, index) => {
@@ -44,7 +46,8 @@ const formatContactInfoData = (responseValue: TResponseDataValue): Record<string
: {};
};
const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
// Export for testing
export const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
let responseData: Record<string, any> = {};
survey.questions.forEach((question) => {
@@ -73,7 +76,8 @@ const extractResponseData = (response: TResponse, survey: TSurvey): Record<strin
return responseData;
};
const mapResponsesToTableData = (
// Export for testing
export const mapResponsesToTableData = (
responses: TResponse[],
survey: TSurvey,
t: TFnType

View File

@@ -0,0 +1,374 @@
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
getResponseCountAction: vi.fn(),
getResponsesAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView",
() => ({
ResponseDataView: vi.fn(() => <div data-testid="response-data-view">ResponseDataView</div>),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter", () => ({
CustomFilter: vi.fn(() => <div data-testid="custom-filter">CustomFilter</div>),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
ResultsShareButton: vi.fn(() => <div data-testid="results-share-button">ResultsShareButton</div>),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
getFormattedFilters: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(),
getResponsesBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: vi.fn((survey) => survey),
}));
vi.mock("next/navigation", () => ({
useParams: vi.fn(),
useSearchParams: vi.fn(),
useRouter: vi.fn(),
usePathname: vi.fn(),
}));
const mockUseResponseFilter = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"))
.useResponseFilter
);
const mockGetResponsesAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
.getResponsesAction
);
const mockGetResponseCountAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
.getResponseCountAction
);
const mockGetResponsesBySurveySharingKeyAction = vi.mocked(
(await import("@/app/share/[sharingKey]/actions")).getResponsesBySurveySharingKeyAction
);
const mockGetResponseCountBySurveySharingKeyAction = vi.mocked(
(await import("@/app/share/[sharingKey]/actions")).getResponseCountBySurveySharingKeyAction
);
const mockUseParams = vi.mocked((await import("next/navigation")).useParams);
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
const mockSurvey = {
id: "survey1",
name: "Test Survey",
questions: [],
thankYouCard: { enabled: true, headline: "Thank You!" },
hiddenFields: { enabled: true, fieldIds: [] },
displayOption: "displayOnce",
recontactDays: 0,
autoClose: null,
triggers: [],
type: "web",
status: "inProgress",
languages: [],
styling: null,
} as unknown as TSurvey;
const mockEnvironment = { id: "env1", name: "Test Environment" } as unknown as TEnvironment;
const mockUser = { id: "user1", name: "Test User" } as TUser;
const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: "env1" } as TTag];
const mockLocale: TUserLocale = "en-US";
const defaultProps = {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: "survey1",
webAppUrl: "http://localhost:3000",
user: mockUser,
environmentTags: mockTags,
responsesPerPage: 10,
locale: mockLocale,
isReadOnly: false,
};
const mockResponseFilterState = {
selectedFilter: "all",
dateRange: { from: undefined, to: undefined },
resetState: vi.fn(),
} as any;
const mockResponses: TResponse[] = [
{
id: "response1",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: true,
data: {},
meta: { userAgent: {} },
notes: [],
tags: [],
} as unknown as TResponse,
{
id: "response2",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: true,
data: {},
meta: { userAgent: {} },
notes: [],
tags: [],
} as unknown as TResponse,
];
describe("ResponsePage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
mockUseParams.mockReturnValue({ environmentId: "env1", surveyId: "survey1" });
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
mockGetResponsesBySurveySharingKeyAction.mockResolvedValue({ data: mockResponses });
mockGetResponseCountBySurveySharingKeyAction.mockResolvedValue({ data: 20 });
mockGetFormattedFilters.mockReturnValue({});
});
test("renders correctly with default props", async () => {
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
expect(mockGetResponseCountAction).toHaveBeenCalled();
expect(mockGetResponsesAction).toHaveBeenCalled();
});
test("does not render ResultsShareButton when isReadOnly is true", async () => {
render(<ResponsePage {...defaultProps} isReadOnly={true} />);
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
});
test("does not render ResultsShareButton when on sharing page", async () => {
mockUseParams.mockReturnValue({ sharingKey: "share123" });
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
expect(mockGetResponseCountBySurveySharingKeyAction).toHaveBeenCalled();
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
});
test("fetches next page of responses", async () => {
const { rerender } = render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(mockGetResponsesAction).toHaveBeenCalledTimes(1);
});
// Simulate calling fetchNextPage (e.g., via ResponseDataView prop)
// For this test, we'll directly manipulate state to simulate the effect
// In a real scenario, this would be triggered by user interaction with ResponseDataView
const responseDataViewProps = vi.mocked(ResponseDataView).mock.calls[0][0];
await act(async () => {
await responseDataViewProps.fetchNextPage();
});
rerender(<ResponsePage {...defaultProps} />); // Rerender to reflect state changes
await waitFor(() => {
expect(mockGetResponsesAction).toHaveBeenCalledTimes(2); // Initial fetch + next page
expect(mockGetResponsesAction).toHaveBeenLastCalledWith(
expect.objectContaining({
offset: defaultProps.responsesPerPage, // page 2
})
);
});
});
test("deletes responses and updates count", async () => {
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(mockGetResponsesAction).toHaveBeenCalledTimes(1);
});
const responseDataViewProps = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"
)
).ResponseDataView
).mock.calls[0][0];
act(() => {
responseDataViewProps.deleteResponses(["response1"]);
});
// Check if ResponseDataView is re-rendered with updated responses
// This requires checking the props passed to ResponseDataView after deletion
// For simplicity, we assume the state update triggers a re-render and ResponseDataView receives new props
await waitFor(async () => {
const latestCallArgs = vi
.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"
)
).ResponseDataView
)
.mock.calls.pop();
if (latestCallArgs) {
expect(latestCallArgs[0].responses).toHaveLength(mockResponses.length - 1);
}
});
});
test("updates a response", async () => {
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(mockGetResponsesAction).toHaveBeenCalledTimes(1);
});
const responseDataViewProps = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"
)
).ResponseDataView
).mock.calls[0][0];
const updatedResponseData = { ...mockResponses[0], finished: false };
act(() => {
responseDataViewProps.updateResponse("response1", updatedResponseData);
});
await waitFor(async () => {
const latestCallArgs = vi
.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"
)
).ResponseDataView
)
.mock.calls.pop();
if (latestCallArgs) {
const updatedResponseInView = latestCallArgs[0].responses.find((r) => r.id === "response1");
expect(updatedResponseInView?.finished).toBe(false);
}
});
});
test("resets pagination and responses when filters change", async () => {
const { rerender } = render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(mockGetResponsesAction).toHaveBeenCalledTimes(1);
});
// Simulate filter change
const newFilterState = { ...mockResponseFilterState, selectedFilter: "completed" };
mockUseResponseFilter.mockReturnValue(newFilterState);
mockGetFormattedFilters.mockReturnValue({ someNewFilter: "value" } as any); // Simulate new formatted filters
rerender(<ResponsePage {...defaultProps} />);
await waitFor(() => {
// Should fetch count and responses again due to filter change
expect(mockGetResponseCountAction).toHaveBeenCalledTimes(2);
expect(mockGetResponsesAction).toHaveBeenCalledTimes(2);
// Check if it fetches with offset 0 (first page)
expect(mockGetResponsesAction).toHaveBeenLastCalledWith(
expect.objectContaining({
offset: 0,
filterCriteria: { someNewFilter: "value" },
})
);
});
});
test("calls resetState when referer search param is not present", () => {
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any);
render(<ResponsePage {...defaultProps} />);
expect(mockResponseFilterState.resetState).toHaveBeenCalled();
});
test("does not call resetState when referer search param is present", () => {
mockUseSearchParams.mockReturnValue({ get: vi.fn().mockReturnValue("someReferer") } as any);
render(<ResponsePage {...defaultProps} />);
expect(mockResponseFilterState.resetState).not.toHaveBeenCalled();
});
test("handles empty responses from API", async () => {
mockGetResponsesAction.mockResolvedValue({ data: [] });
mockGetResponseCountAction.mockResolvedValue({ data: 0 });
render(<ResponsePage {...defaultProps} />);
await waitFor(async () => {
const latestCallArgs = vi
.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"
)
).ResponseDataView
)
.mock.calls.pop();
if (latestCallArgs) {
expect(latestCallArgs[0].responses).toEqual([]);
expect(latestCallArgs[0].hasMore).toBe(false);
}
});
});
test("handles API errors gracefully for getResponsesAction", async () => {
mockGetResponsesAction.mockResolvedValue({ data: null as any });
render(<ResponsePage {...defaultProps} />);
await waitFor(async () => {
const latestCallArgs = vi
.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView"
)
).ResponseDataView
)
.mock.calls.pop();
if (latestCallArgs) {
expect(latestCallArgs[0].responses).toEqual([]); // Should default to empty array
expect(latestCallArgs[0].isFetchingFirstPage).toBe(false);
}
});
});
test("handles API errors gracefully for getResponseCountAction", async () => {
mockGetResponseCountAction.mockResolvedValue({ data: null as any });
render(<ResponsePage {...defaultProps} />);
// No direct visual change, but ensure no crash and component renders
await waitFor(() => {
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,487 @@
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import type { DragEndEvent } from "@dnd-kit/core";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "./ResponseTable";
// Hoist variables used in mock factories
const { DndContextMock, SortableContextMock, arrayMoveMock } = vi.hoisted(() => {
const dndMock = vi.fn(({ children, onDragEnd }) => {
// Store the onDragEnd prop to allow triggering it in tests
(dndMock as any).lastOnDragEnd = onDragEnd;
return <div data-testid="dnd-context">{children}</div>;
});
const sortableMock = vi.fn(({ children }) => <>{children}</>);
const moveMock = vi.fn((array, from, to) => {
const newArray = [...array];
const [item] = newArray.splice(from, 1);
newArray.splice(to, 0, item);
return newArray;
});
return {
DndContextMock: dndMock,
SortableContextMock: sortableMock,
arrayMoveMock: moveMock,
};
});
vi.mock("@dnd-kit/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
return {
...actual,
DndContext: DndContextMock,
useSensor: vi.fn(),
useSensors: vi.fn(),
closestCenter: vi.fn(),
};
});
vi.mock("@dnd-kit/modifiers", () => ({
restrictToHorizontalAxis: vi.fn(),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: SortableContextMock,
arrayMove: arrayMoveMock,
horizontalListSortingStrategy: vi.fn(),
}));
// Mock child components and hooks
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
() => ({
ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) =>
open ? (
<div data-testid="response-card-modal">
Selected Response ID: {selectedResponseId}
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
</div>
) : null
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
() => ({
ResponseTableCell: vi.fn(({ cell, row, setSelectedResponseId }) => (
<td data-testid={`cell-${cell.id}`} onClick={() => setSelectedResponseId(row.original.responseId)}>
{typeof cell.getValue === "function" ? cell.getValue() : JSON.stringify(cell.getValue())}
</td>
)),
})
);
const mockGeneratedColumns = [
{
id: "select",
header: () => "Select",
cell: vi.fn(() => "SelectCell"),
enableSorting: false,
meta: { type: "select", questionType: null, hidden: false },
},
{
id: "createdAt",
header: () => "Created At",
cell: vi.fn(({ row }) => new Date(row.original.createdAt).toISOString()),
enableSorting: true,
meta: { type: "createdAt", questionType: null, hidden: false },
},
{
id: "q1",
header: () => "Question 1",
cell: vi.fn(({ row }) => row.original.responseData.q1),
enableSorting: true,
meta: { type: "question", questionType: "openText", hidden: false },
},
];
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns",
() => ({
generateResponseTableColumns: vi.fn(() => mockGeneratedColumns),
})
);
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
deleteResponseAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/data-table", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/ui/components/data-table")>();
return {
...actual,
DataTableToolbar: vi.fn((props) => (
<div data-testid="data-table-toolbar">
<button data-testid="toolbar-expand-toggle" onClick={() => props.setIsExpanded(!props.isExpanded)}>
Toggle Expand
</button>
<button data-testid="toolbar-open-settings" onClick={() => props.setIsTableSettingsModalOpen(true)}>
Open Settings
</button>
<button
data-testid="toolbar-delete-selected"
onClick={() => props.deleteRows(props.table.getSelectedRowModel().rows.map((r) => r.id))}>
Delete Selected
</button>
<button data-testid="toolbar-delete-single" onClick={() => props.deleteAction("single_response_id")}>
Delete Single Action
</button>
</div>
)),
DataTableHeader: vi.fn(({ header }) => (
<th
data-testid={`header-${header.id}`}
onClick={() => header.column.getToggleSortingHandler()?.(new MouseEvent("click"))}>
{typeof header.column.columnDef.header === "function"
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
<button
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
data-testid={`resize-${header.id}`}>
Resize
</button>
</th>
)),
DataTableSettingsModal: vi.fn(({ open, setOpen }) =>
open ? (
<div data-testid="data-table-settings-modal">
<button onClick={() => setOpen(false)}>Close Settings</button>
</div>
) : null
),
};
});
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: vi.fn((key) => key), // Simple pass-through mock
}),
}));
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString();
}),
clear: () => {
store = {};
},
removeItem: vi.fn((key: string) => {
delete store[key];
}),
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
} as unknown as TSurveyQuestion,
],
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
welcomeCard: {
enabled: false,
headline: { default: "" },
html: { default: "" },
timeToFinish: false,
showResponseCount: false,
},
autoClose: null,
delay: 0,
autoComplete: null,
closeOnDate: null,
displayOption: "displayOnce",
recontactDays: null,
singleUse: { enabled: false, isEncrypted: true },
triggers: [],
languages: [],
styling: null,
surveyClosedMessage: null,
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;
const mockResponses: TResponse[] = [
{
id: "res1",
surveyId: "survey1",
finished: true,
data: { q1: "Response 1 Text" },
createdAt: new Date("2023-01-01T10:00:00.000Z"),
updatedAt: new Date(),
meta: {},
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "en",
contact: null,
contactAttributes: null,
},
{
id: "res2",
surveyId: "survey1",
finished: false,
data: { q1: "Response 2 Text" },
createdAt: new Date("2023-01-02T10:00:00.000Z"),
updatedAt: new Date(),
meta: {},
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "en",
contact: null,
contactAttributes: null,
},
];
const mockResponseTableData: TResponseTableData[] = [
{
responseId: "res1",
responseData: { q1: "Response 1 Text" },
createdAt: new Date("2023-01-01T10:00:00.000Z"),
status: "Completed",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "en",
person: null,
contactAttributes: null,
},
{
responseId: "res2",
responseData: { q1: "Response 2 Text" },
createdAt: new Date("2023-01-02T10:00:00.000Z"),
status: "Not Completed",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "en",
person: null,
contactAttributes: null,
},
];
const mockEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockUser = {
id: "user1",
name: "Test User",
email: "user@test.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
notificationSettings: { alert: {}, weeklySummary: {} },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
];
const mockLocale: TUserLocale = "en-US";
const defaultProps = {
data: mockResponseTableData,
survey: mockSurvey,
responses: mockResponses,
environment: mockEnvironment,
user: mockUser,
environmentTags: mockEnvironmentTags,
isReadOnly: false,
fetchNextPage: vi.fn(),
hasMore: true,
deleteResponses: vi.fn(),
updateResponse: vi.fn(),
isFetchingFirstPage: false,
locale: mockLocale,
};
describe("ResponseTable", () => {
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
});
test("renders skeleton when isFetchingFirstPage is true", () => {
render(<ResponseTable {...defaultProps} isFetchingFirstPage={true} />);
// Check for skeleton elements (implementation detail, might need adjustment)
// For now, check that data is not directly rendered
expect(screen.queryByText("Response 1 Text")).not.toBeInTheDocument();
// Check if table headers are still there
expect(screen.getByText("Created At")).toBeInTheDocument();
});
test("loads settings from localStorage on mount", () => {
const savedOrder = ["q1", "createdAt", "select"];
const savedVisibility = { createdAt: false };
const savedExpanded = true;
localStorageMock.setItem(`${mockSurvey.id}-columnOrder`, JSON.stringify(savedOrder));
localStorageMock.setItem(`${mockSurvey.id}-columnVisibility`, JSON.stringify(savedVisibility));
localStorageMock.setItem(`${mockSurvey.id}-rowExpand`, JSON.stringify(savedExpanded));
render(<ResponseTable {...defaultProps} />);
// Check if generateResponseTableColumns was called with the loaded expanded state
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
mockSurvey,
savedExpanded,
false,
expect.any(Function)
);
});
test("saves settings to localStorage when they change", async () => {
const { rerender } = render(<ResponseTable {...defaultProps} />);
// Simulate column order change via DND
const dragEvent: DragEndEvent = {
active: { id: "createdAt" },
over: { id: "q1" },
delta: { x: 0, y: 0 },
activators: { x: 0, y: 0 },
collisions: null,
overNode: null,
activeNode: null,
} as any;
act(() => {
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
});
rerender(<ResponseTable {...defaultProps} />); // Rerender to reflect state change if necessary for useEffect
expect(localStorageMock.setItem).toHaveBeenCalledWith(
`${mockSurvey.id}-columnOrder`,
JSON.stringify(["select", "q1", "createdAt"])
);
// Simulate visibility change (e.g. via settings modal - direct state change for test)
// This would typically happen via table.setColumnVisibility, which is internal to useReactTable
// For this test, we'll assume a mechanism changes columnVisibility state
// This part is hard to test without deeper mocking of useReactTable or exposing setColumnVisibility
// Simulate row expansion change
await userEvent.click(screen.getByTestId("toolbar-expand-toggle")); // Toggle to true
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
});
test("handles column drag and drop", () => {
render(<ResponseTable {...defaultProps} />);
const dragEvent: DragEndEvent = {
active: { id: "createdAt" },
over: { id: "q1" },
delta: { x: 0, y: 0 },
activators: { x: 0, y: 0 },
collisions: null,
overNode: null,
activeNode: null,
} as any;
act(() => {
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
});
expect(arrayMoveMock).toHaveBeenCalledWith(expect.arrayContaining(["createdAt", "q1"]), 1, 2); // Example indices
expect(localStorageMock.setItem).toHaveBeenCalledWith(
`${mockSurvey.id}-columnOrder`,
JSON.stringify(["select", "q1", "createdAt"]) // Based on initial ['select', 'createdAt', 'q1']
);
});
test("interacts with DataTableToolbar: toggle expand, open settings, delete", async () => {
const deleteResponsesMock = vi.fn();
const deleteResponseActionMock = vi.mocked(deleteResponseAction);
render(<ResponseTable {...defaultProps} deleteResponses={deleteResponsesMock} />);
// Toggle expand
await userEvent.click(screen.getByTestId("toolbar-expand-toggle"));
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
mockSurvey,
true,
false,
expect.any(Function)
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
// Open settings
await userEvent.click(screen.getByTestId("toolbar-open-settings"));
expect(screen.getByTestId("data-table-settings-modal")).toBeInTheDocument();
await userEvent.click(screen.getByText("Close Settings"));
expect(screen.queryByTestId("data-table-settings-modal")).not.toBeInTheDocument();
// Delete selected (mock table selection)
// This requires mocking table.getSelectedRowModel().rows
// For simplicity, we assume the toolbar button calls deleteRows correctly
// The mock for DataTableToolbar calls props.deleteRows with hardcoded IDs for now.
// To test properly, we'd need to mock table.getSelectedRowModel
// For now, let's assume the mock toolbar calls it.
// await userEvent.click(screen.getByTestId("toolbar-delete-selected"));
// expect(deleteResponsesMock).toHaveBeenCalledWith(["row1_id", "row2_id"]); // From mock toolbar
// Delete single action
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
expect(deleteResponseActionMock).toHaveBeenCalledWith({ responseId: "single_response_id" });
});
test("calls fetchNextPage when 'Load More' is clicked", async () => {
const fetchNextPageMock = vi.fn();
render(<ResponseTable {...defaultProps} fetchNextPage={fetchNextPageMock} />);
await userEvent.click(screen.getByText("common.load_more"));
expect(fetchNextPageMock).toHaveBeenCalled();
});
test("does not show 'Load More' if hasMore is false", () => {
render(<ResponseTable {...defaultProps} hasMore={false} />);
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
});
test("shows 'No results' when data is empty", () => {
render(<ResponseTable {...defaultProps} data={[]} responses={[]} />);
expect(screen.getByText("common.no_results")).toBeInTheDocument();
});
test("deleteResponse function calls deleteResponseAction", async () => {
render(<ResponseTable {...defaultProps} />);
// This function is called by DataTableToolbar's deleteAction prop
// We can trigger it via the mocked DataTableToolbar
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
expect(vi.mocked(deleteResponseAction)).toHaveBeenCalledWith({ responseId: "single_response_id" });
});
});

View File

@@ -0,0 +1,259 @@
import { processResponseData } from "@/lib/responses";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { generateResponseTableColumns } from "./ResponseTableColumns";
// Mock TFnType
const t = vi.fn((key: string, params?: any) => {
if (params) {
let message = key;
for (const p in params) {
message = message.replace(`{{${p}}}`, params[p]);
}
return message;
}
return key;
});
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn((localizedString, locale) => localizedString[locale] || localizedString.default),
}));
vi.mock("@/lib/responses", () => ({
processResponseData: vi.fn((data) => (Array.isArray(data) ? data.join(", ") : String(data))),
}));
vi.mock("@/lib/utils/contact", () => ({
getContactIdentifier: vi.fn((person) => person?.attributes?.email || person?.id || "Anonymous"),
}));
vi.mock("@/lib/utils/datetime", () => ({
getFormattedDateTimeString: vi.fn((date) => new Date(date).toISOString()),
}));
vi.mock("@/lib/utils/recall", () => ({
recallToHeadline: vi.fn((headline) => headline),
}));
vi.mock("@/modules/analysis/components/SingleResponseCard/components/RenderResponse", () => ({
RenderResponse: vi.fn(({ responseData, isExpanded }) => (
<div>
RenderResponse: {JSON.stringify(responseData)} (Expanded: {String(isExpanded)})
</div>
)),
}));
vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIconMap: vi.fn(() => ({
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
})),
VARIABLES_ICON_MAP: {
text: <span>VarT</span>,
number: <span>VarN</span>,
},
}));
vi.mock("@/modules/ui/components/data-table", () => ({
getSelectionColumn: vi.fn(() => ({
id: "select",
header: "Select",
cell: "SelectCell",
})),
}));
vi.mock("@/modules/ui/components/response-badges", () => ({
ResponseBadges: vi.fn(({ items, isExpanded }) => (
<div>
Badges: {items.join(", ")} (Expanded: {String(isExpanded)})
</div>
)),
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }) => <div>{children}</div>,
TooltipContent: ({ children }) => <div>{children}</div>,
TooltipProvider: ({ children }) => <div>{children}</div>,
TooltipTrigger: ({ children }) => <div>{children}</div>,
}));
vi.mock("next/link", () => ({
default: ({ children, href }) => <a href={href}>{children}</a>,
}));
vi.mock("lucide-react", () => ({
CircleHelpIcon: () => <span>Help</span>,
EyeOffIcon: () => <span>EyeOff</span>,
MailIcon: () => <span>Mail</span>,
TagIcon: () => <span>Tag</span>,
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "inProgress",
questions: [
{
id: "q1open",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text Question" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2matrix",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
rows: [{ default: "Row1" }, { default: "Row2" }],
columns: [{ default: "Col1" }, { default: "Col2" }],
required: false,
} as unknown as TSurveyQuestion,
{
id: "q3address",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Address Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q4contact",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
],
variables: [
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
{ id: "var2", name: "Total Spend", type: "number" } as TSurveyVariable,
],
hiddenFields: { enabled: true, fieldIds: ["hf1", "hf2"] },
endings: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
isVerifyEmailEnabled: false,
styling: null,
languages: [],
segment: null,
projectOverwrites: null,
singleUse: null,
pin: null,
resultShareKey: null,
surveyClosedMessage: null,
welcomeCard: {
enabled: false,
} as TSurvey["welcomeCard"],
} as unknown as TSurvey;
const mockResponseData = {
contactAttributes: { country: "USA" },
responseData: {
q1open: "Open text answer",
Row1: "Col1", // For matrix q2matrix
Row2: "Col2",
addressLine1: "123 Main St",
city: "Anytown",
firstName: "John",
email: "john.doe@example.com",
hf1: "Hidden Field 1 Value",
},
variables: {
var1: "Segment A",
var2: 100,
},
notes: [
{
id: "note1",
text: "This is a note",
updatedAt: new Date(),
user: { name: "User" } as unknown as TResponseNoteUser,
} as TResponseNote,
],
status: "completed",
tags: [{ id: "tag1", name: "Important" } as unknown as TTag],
language: "default",
} as unknown as TResponseTableData;
describe("generateResponseTableColumns", () => {
beforeEach(() => {
vi.clearAllMocks();
t.mockImplementation((key: string) => key); // Reset t mock for each test
});
afterEach(() => {
cleanup();
});
test("should include selection column when not read-only", () => {
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any);
expect(columns[0].id).toBe("select");
expect(vi.mocked(getSelectionColumn)).toHaveBeenCalledTimes(1);
});
test("should not include selection column when read-only", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
expect(columns[0].id).not.toBe("select");
expect(vi.mocked(getSelectionColumn)).not.toHaveBeenCalled();
});
test("should include Verified Email column when survey.isVerifyEmailEnabled is true", () => {
const surveyWithVerifiedEmail = { ...mockSurvey, isVerifyEmailEnabled: true };
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any);
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(true);
});
test("should not include Verified Email column when survey.isVerifyEmailEnabled is false", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(false);
});
test("should generate columns for variables", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const var1Col = columns.find((col) => (col as any).accessorKey === "var1");
expect(var1Col).toBeDefined();
const var1Cell = (var1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(var1Cell.props.children).toBe("Segment A");
const var2Col = columns.find((col) => (col as any).accessorKey === "var2");
expect(var2Col).toBeDefined();
const var2Cell = (var2Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(var2Cell.props.children).toBe(100);
});
test("should generate columns for hidden fields if fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hf1Col).toBeDefined();
const hf1Cell = (hf1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(hf1Cell.props.children).toBe("Hidden Field 1 Value");
});
test("should not generate columns for hidden fields if fieldIds is undefined", () => {
const surveyWithoutHiddenFieldIds = { ...mockSurvey, hiddenFields: { enabled: true } };
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any);
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hf1Col).toBeUndefined();
});
test("should generate Notes column", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesCol = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesCol).toBeDefined();
(notesCol?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
});
});

View File

@@ -0,0 +1,241 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation",
() => ({
SurveyAnalysisNavigation: vi.fn(() => <div data-testid="survey-analysis-navigation"></div>),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage",
() => ({
ResponsePage: vi.fn(() => <div data-testid="response-page"></div>),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA",
() => ({
SurveyAnalysisCTA: vi.fn(() => <div data-testid="survey-analysis-cta"></div>),
})
);
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
}));
vi.mock("@/lib/getSurveyUrl", () => ({
getSurveyDomain: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/lib/tag/service", () => ({
getTagsByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div data-testid="page-content-wrapper">{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle, children, cta }) => (
<div data-testid="page-header">
<h1 data-testid="page-title">{pageTitle}</h1>
{cta}
{children}
</div>
)),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
const mockEnvironmentId = "test-env-id";
const mockSurveyId = "test-survey-id";
const mockUserId = "test-user-id";
const mockSurvey: TSurvey = {
id: mockSurveyId,
name: "Test Survey",
environmentId: mockEnvironmentId,
status: "inProgress",
type: "web",
questions: [],
thankYouCard: { enabled: false },
endings: [],
languages: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
styling: null,
} as unknown as TSurvey;
const mockUser = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
role: "project_manager",
createdAt: new Date(),
updatedAt: new Date(),
locale: "en-US",
} as unknown as TUser;
const mockEnvironment = {
id: mockEnvironmentId,
type: "production",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: true,
} as unknown as TEnvironment;
const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: mockEnvironmentId } as unknown as TTag];
const mockLocale: TUserLocale = "en-US";
const mockSurveyDomain = "http://customdomain.com";
const mockParams = {
environmentId: mockEnvironmentId,
surveyId: mockSurveyId,
};
describe("ResponsesPage", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: { user: { id: mockUserId } } as any,
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("renders correctly with all data", async () => {
const props = { params: mockParams };
const jsx = await Page(props);
render(jsx);
await screen.findByTestId("page-content-wrapper");
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("page-title")).toHaveTextContent(mockSurvey.name);
expect(screen.getByTestId("survey-analysis-cta")).toBeInTheDocument();
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
expect(screen.getByTestId("response-page")).toBeInTheDocument();
expect(vi.mocked(SurveyAnalysisCTA)).toHaveBeenCalledWith(
expect.objectContaining({
environment: mockEnvironment,
survey: mockSurvey,
isReadOnly: false,
user: mockUser,
surveyDomain: mockSurveyDomain,
responseCount: 10,
}),
undefined
);
expect(vi.mocked(SurveyAnalysisNavigation)).toHaveBeenCalledWith(
expect.objectContaining({
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "responses",
initialTotalResponseCount: 10,
}),
undefined
);
expect(vi.mocked(ResponsePage)).toHaveBeenCalledWith(
expect.objectContaining({
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
webAppUrl: "http://localhost:3000",
environmentTags: mockTags,
user: mockUser,
responsesPerPage: 10,
locale: mockLocale,
isReadOnly: false,
}),
undefined
);
});
test("throws error if survey not found", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const props = { params: mockParams };
await expect(Page(props)).rejects.toThrow("common.survey_not_found");
});
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
const props = { params: mockParams };
await expect(Page(props)).rejects.toThrow("common.user_not_found");
});
});

View File

@@ -0,0 +1,67 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import ScrollToTop from "./ScrollToTop";
const containerId = "test-container";
describe("ScrollToTop", () => {
let mockContainer: HTMLElement;
beforeEach(() => {
mockContainer = document.createElement("div");
mockContainer.id = containerId;
mockContainer.scrollTop = 0;
mockContainer.scrollTo = vi.fn();
mockContainer.addEventListener = vi.fn();
mockContainer.removeEventListener = vi.fn();
vi.spyOn(document, "getElementById").mockReturnValue(mockContainer);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test("renders hidden initially", () => {
render(<ScrollToTop containerId={containerId} />);
const button = screen.getByRole("button");
expect(button).toHaveClass("opacity-0");
});
test("calls scrollTo on button click", async () => {
render(<ScrollToTop containerId={containerId} />);
const button = screen.getByRole("button");
// Make button visible
mockContainer.scrollTop = 301;
const scrollEvent = new Event("scroll");
mockContainer.dispatchEvent(scrollEvent);
await userEvent.click(button);
expect(mockContainer.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
});
test("does nothing if container is not found", () => {
vi.spyOn(document, "getElementById").mockReturnValue(null);
render(<ScrollToTop containerId="non-existent-container" />);
const button = screen.getByRole("button");
expect(button).toHaveClass("opacity-0"); // Stays hidden
// Try to simulate scroll (though no listener would be attached)
fireEvent.scroll(window, { target: { scrollY: 400 } });
expect(button).toHaveClass("opacity-0");
// Try to click
userEvent.click(button);
// No error should occur, and scrollTo should not be called on a null element
});
test("removes event listener on unmount", () => {
const { unmount } = render(<ScrollToTop containerId={containerId} />);
expect(mockContainer.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
unmount();
expect(mockContainer.removeEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
});
});

View File

@@ -0,0 +1,287 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LucideIcon } from "lucide-react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveySingleUse,
} from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
// Mock data
const mockSurveyWeb = {
id: "survey1",
name: "Web Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
} as unknown as TSurveyQuestion,
],
displayOption: "displayOnce",
recontactDays: 0,
autoClose: null,
delay: 0,
autoComplete: null,
runOnDate: null,
closeOnDate: null,
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey;
const mockSurveyLink = {
...mockSurveyWeb,
id: "survey2",
name: "Link Survey",
type: "link",
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
} as unknown as TSurvey;
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
role: "project_manager",
objective: "other",
createdAt: new Date(),
updatedAt: new Date(),
locale: "en-US",
} as unknown as TUser;
// Mocks
const mockRouterRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: mockRouterRefresh,
}),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (str: string) => str,
}),
}));
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(() => <div>ShareSurveyLinkMock</div>),
}));
vi.mock("@/modules/ui/components/badge", () => ({
Badge: vi.fn(({ text }) => <span data-testid="badge-mock">{text}</span>),
}));
const mockEmbedViewComponent = vi.fn();
vi.mock("./shareEmbedModal/EmbedView", () => ({
EmbedView: (props: any) => mockEmbedViewComponent(props),
}));
const mockPanelInfoViewComponent = vi.fn();
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props),
}));
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
vi.mock("@/modules/ui/components/dialog", async () => {
const actual = await vi.importActual<typeof import("@/modules/ui/components/dialog")>(
"@/modules/ui/components/dialog"
);
return {
...actual,
Dialog: (props: React.ComponentProps<typeof actual.Dialog>) => {
capturedDialogOnOpenChange = props.onOpenChange;
return <actual.Dialog {...props} />;
},
// DialogTitle, DialogContent, DialogDescription will be the actual components
// due to ...actual spread and no specific mock for them here.
};
});
describe("ShareEmbedSurvey", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
capturedDialogOnOpenChange = undefined;
});
const mockSetOpen = vi.fn();
const defaultProps = {
survey: mockSurveyWeb,
surveyDomain: "test.com",
open: true,
modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen,
user: mockUser,
};
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, surveyDomain, locale }) => (
<div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="embedview-activeid">{activeId}</div>
<div data-testid="embedview-survey-id">{survey.id}</div>
<div data-testid="embedview-email">{email}</div>
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
<div data-testid="embedview-surveyDomain">{surveyDomain}</div>
<div data-testid="embedview-locale">{locale}</div>
</div>
)
);
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
));
});
test("renders initial 'start' view correctly when open and modalView is 'start'", () => {
render(<ShareEmbedSurvey {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
await userEvent.click(embedButton);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
});
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
await userEvent.click(panelButton);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("calls setOpen(false) when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("calls setOpen(false) when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} survey={mockSurveyWeb} />);
expect(capturedDialogOnOpenChange).toBeDefined();
// Simulate Dialog closing
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(false);
expect(mockSetOpen).toHaveBeenCalledWith(false);
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
// Simulate Dialog opening
mockRouterRefresh.mockClear();
mockSetOpen.mockClear();
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(true);
expect(mockSetOpen).toHaveBeenCalledWith(true);
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
});
test("correctly configures for 'link' survey type in embed view", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(3);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("email");
expect(embedViewProps.activeId).toBe("email");
});
test("correctly configures for 'web' survey type in embed view", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />);
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(1);
expect(embedViewProps.tabs[0].id).toBe("app");
expect(embedViewProps.activeId).toBe("app");
});
test("useEffect does not change activeId if survey.type changes from web to link (while in embed view)", () => {
const { rerender } = render(
<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />
);
expect(vi.mocked(mockEmbedViewComponent).mock.calls[0][0].activeId).toBe("app");
rerender(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(vi.mocked(mockEmbedViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior
});
test("initial showView is set by modalView prop when open is true", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
// The main check is that the previous view ('embed') is gone.
});
test("renders correct label for link tab based on singleUse survey property", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
let embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
expect(linkTab?.label).toBe("environments.surveys.summary.share_the_link");
cleanup();
vi.mocked(mockEmbedViewComponent).mockClear();
const mockSurveyLinkSingleUse: TSurvey = {
...mockSurveyLink,
singleUse: { enabled: true, isEncrypted: true },
};
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="embed" />);
embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
expect(linkTab?.label).toBe("environments.surveys.summary.single_use_links");
});
});

View File

@@ -0,0 +1,137 @@
import { ShareSurveyResults } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock Button
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, onClick, asChild, ...props }: any) => {
if (asChild) {
// For 'asChild', Button renders its children, potentially passing props via Slot.
// Mocking simply renders children inside a div that can receive Button's props.
return <div {...props}>{children}</div>;
}
return (
<button onClick={onClick} {...props}>
{children}
</button>
);
}),
}));
// Mock Modal
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: vi.fn(() => ({
t: (key: string) => key,
})),
}));
// Mock Next Link
vi.mock("next/link", () => ({
default: vi.fn(({ children, href, target, rel, ...props }) => (
<a href={href} target={target} rel={rel} {...props}>
{children}
</a>
)),
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockSetOpen = vi.fn();
const mockHandlePublish = vi.fn();
const mockHandleUnpublish = vi.fn();
const surveyUrl = "https://app.formbricks.com/s/some-survey-id";
const defaultProps = {
open: true,
setOpen: mockSetOpen,
handlePublish: mockHandlePublish,
handleUnpublish: mockHandleUnpublish,
showPublishModal: false,
surveyUrl: "",
};
describe("ShareSurveyResults", () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock navigator.clipboard
Object.defineProperty(global.navigator, "clipboard", {
value: {
writeText: vi.fn(() => Promise.resolve()),
},
configurable: true,
});
});
afterEach(() => {
cleanup();
});
test("renders publish warning when showPublishModal is false", async () => {
render(<ShareSurveyResults {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.publish_to_web_warning_description")
).toBeInTheDocument();
const publishButton = screen.getByText("environments.surveys.summary.publish_to_web");
expect(publishButton).toBeInTheDocument();
await userEvent.click(publishButton);
expect(mockHandlePublish).toHaveBeenCalledTimes(1);
});
test("renders survey public info when showPublishModal is true and surveyUrl is provided", async () => {
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl={surveyUrl} />);
expect(screen.getByText("environments.surveys.summary.survey_results_are_public")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")
).toBeInTheDocument();
expect(screen.getByText(surveyUrl)).toBeInTheDocument();
const copyButton = screen.getByRole("button", { name: "Copy survey link to clipboard" });
expect(copyButton).toBeInTheDocument();
await userEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("common.link_copied");
const unpublishButton = screen.getByText("environments.surveys.summary.unpublish_from_web");
expect(unpublishButton).toBeInTheDocument();
await userEvent.click(unpublishButton);
expect(mockHandleUnpublish).toHaveBeenCalledTimes(1);
const viewSiteLink = screen.getByText("environments.surveys.summary.view_site");
expect(viewSiteLink).toBeInTheDocument();
const anchor = viewSiteLink.closest("a");
expect(anchor).toHaveAttribute("href", surveyUrl);
expect(anchor).toHaveAttribute("target", "_blank");
expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
});
test("does not render content when modal is closed (open is false)", () => {
render(<ShareSurveyResults {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public")
).not.toBeInTheDocument();
});
test("renders publish warning if surveyUrl is empty even if showPublishModal is true", () => {
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl="" />);
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public")
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,185 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { useSearchParams } from "next/navigation";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TLanguage } from "@formbricks/types/project";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SuccessMessage } from "./SuccessMessage";
// Mock Confetti
vi.mock("@/modules/ui/components/confetti", () => ({
Confetti: vi.fn(() => <div data-testid="confetti-mock" />),
}));
// Mock useSearchParams from next/navigation
vi.mock("next/navigation", () => ({
useSearchParams: vi.fn(),
usePathname: vi.fn(() => "/"), // Default mock for usePathname if ever needed by underlying logic
useRouter: vi.fn(() => ({ push: vi.fn() })), // Default mock for useRouter
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
},
}));
const mockReplaceState = vi.fn();
describe("SuccessMessage", () => {
let mockUrlSearchParamsGet: ReturnType<typeof vi.fn>;
const mockEnvironmentBase = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockSurveyBase = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: "env1",
status: "draft",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
delay: 0,
autoComplete: null,
runOnDate: null,
closeOnDate: null,
welcomeCard: {
enabled: false,
headline: { default: "" },
html: { default: "" },
} as unknown as TSurvey["welcomeCard"],
triggers: [],
languages: [
{
default: true,
enabled: true,
language: { id: "lang1", code: "en", alias: null } as unknown as TLanguage,
},
],
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;
beforeEach(() => {
vi.clearAllMocks(); // Clears mock calls, instances, contexts and results
mockUrlSearchParamsGet = vi.fn();
vi.mocked(useSearchParams).mockReturnValue({
get: mockUrlSearchParamsGet,
} as any);
Object.defineProperty(window, "location", {
value: new URL("http://localhost/somepath"),
writable: true,
});
Object.defineProperty(window, "history", {
value: {
replaceState: mockReplaceState,
pushState: vi.fn(),
go: vi.fn(),
},
writable: true,
});
mockReplaceState.mockClear(); // Ensure replaceState mock is clean for each test
});
afterEach(() => {
cleanup();
});
test("should show 'almost_there' toast and confetti for app survey with widget not setup when success param is present", async () => {
mockUrlSearchParamsGet.mockImplementation((param) => (param === "success" ? "true" : null));
const environment: TEnvironment = { ...mockEnvironmentBase, appSetupCompleted: false };
const survey: TSurvey = { ...mockSurveyBase, type: "app" };
render(<SuccessMessage environment={environment} survey={survey} />);
await waitFor(() => {
expect(screen.getByTestId("confetti-mock")).toBeInTheDocument();
});
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.almost_there", {
id: "survey-publish-success-toast",
icon: "🤏",
duration: 5000,
position: "bottom-right",
});
expect(mockReplaceState).toHaveBeenCalledWith({}, "", "http://localhost/somepath");
});
test("should show 'congrats' toast and confetti for app survey with widget setup when success param is present", async () => {
mockUrlSearchParamsGet.mockImplementation((param) => (param === "success" ? "true" : null));
const environment: TEnvironment = { ...mockEnvironmentBase, appSetupCompleted: true };
const survey: TSurvey = { ...mockSurveyBase, type: "app" };
render(<SuccessMessage environment={environment} survey={survey} />);
await waitFor(() => {
expect(screen.getByTestId("confetti-mock")).toBeInTheDocument();
});
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.congrats", {
id: "survey-publish-success-toast",
icon: "🎉",
duration: 5000,
position: "bottom-right",
});
expect(mockReplaceState).toHaveBeenCalledWith({}, "", "http://localhost/somepath");
});
test("should show 'congrats' toast, confetti, and update URL for link survey when success param is present", async () => {
mockUrlSearchParamsGet.mockImplementation((param) => (param === "success" ? "true" : null));
const environment: TEnvironment = { ...mockEnvironmentBase };
const survey: TSurvey = { ...mockSurveyBase, type: "link" };
Object.defineProperty(window, "location", {
value: new URL("http://localhost/somepath?success=true"), // initial URL with success
writable: true,
});
render(<SuccessMessage environment={environment} survey={survey} />);
await waitFor(() => {
expect(screen.getByTestId("confetti-mock")).toBeInTheDocument();
});
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.congrats", {
id: "survey-publish-success-toast",
icon: "🎉",
duration: 5000,
position: "bottom-right",
});
expect(mockReplaceState).toHaveBeenCalledWith({}, "", "http://localhost/somepath?share=true");
});
test("should not show confetti or toast if success param is not present", () => {
mockUrlSearchParamsGet.mockImplementation((param) => null);
const environment: TEnvironment = { ...mockEnvironmentBase };
const survey: TSurvey = { ...mockSurveyBase, type: "app" };
render(<SuccessMessage environment={environment} survey={survey} />);
expect(screen.queryByTestId("confetti-mock")).not.toBeInTheDocument();
expect(toast.success).not.toHaveBeenCalled();
expect(mockReplaceState).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,468 @@
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary";
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { cleanup, render, screen } from "@testing-library/react";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import {
TI18nString,
TSurvey,
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { SummaryList } from "./SummaryList";
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys",
() => ({
EmptyAppSurveys: vi.fn(() => <div>Mocked EmptyAppSurveys</div>),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary",
() => ({
CTASummary: vi.fn(({ questionSummary }) => <div>Mocked CTASummary: {questionSummary.question.id}</div>),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary",
() => ({
CalSummary: vi.fn(({ questionSummary }) => <div>Mocked CalSummary: {questionSummary.question.id}</div>),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary",
() => ({
ConsentSummary: vi.fn(({ questionSummary }) => (
<div>Mocked ConsentSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary",
() => ({
ContactInfoSummary: vi.fn(({ questionSummary }) => (
<div>Mocked ContactInfoSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary",
() => ({
DateQuestionSummary: vi.fn(({ questionSummary }) => (
<div>Mocked DateQuestionSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary",
() => ({
FileUploadSummary: vi.fn(({ questionSummary }) => (
<div>Mocked FileUploadSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary",
() => ({
HiddenFieldsSummary: vi.fn(({ questionSummary }) => (
<div>Mocked HiddenFieldsSummary: {questionSummary.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary",
() => ({
MatrixQuestionSummary: vi.fn(({ questionSummary }) => (
<div>Mocked MatrixQuestionSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary",
() => ({
MultipleChoiceSummary: vi.fn(({ questionSummary }) => (
<div>Mocked MultipleChoiceSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary",
() => ({
NPSSummary: vi.fn(({ questionSummary }) => <div>Mocked NPSSummary: {questionSummary.question.id}</div>),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary",
() => ({
OpenTextSummary: vi.fn(({ questionSummary }) => (
<div>Mocked OpenTextSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary",
() => ({
PictureChoiceSummary: vi.fn(({ questionSummary }) => (
<div>Mocked PictureChoiceSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary",
() => ({
RankingSummary: vi.fn(({ questionSummary }) => (
<div>Mocked RankingSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary",
() => ({
RatingSummary: vi.fn(({ questionSummary }) => (
<div>Mocked RatingSummary: {questionSummary.question.id}</div>
)),
})
);
vi.mock("./AddressSummary", () => ({
AddressSummary: vi.fn(({ questionSummary }) => (
<div>Mocked AddressSummary: {questionSummary.question.id}</div>
)),
}));
// Mock hooks and utils
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn((label, _) => (typeof label === "string" ? label : label.default)),
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: vi.fn(() => <div>Mocked EmptySpaceFiller</div>),
}));
vi.mock("@/modules/ui/components/skeleton-loader", () => ({
SkeletonLoader: vi.fn(() => <div>Mocked SkeletonLoader</div>),
}));
vi.mock("react-hot-toast", () => ({
// This mock setup is for a named export 'toast'
toast: {
success: vi.fn(),
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils", () => ({
constructToastMessage: vi.fn(),
}));
const mockEnvironment = {
id: "env_test_id",
type: "production",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: true,
} as unknown as TEnvironment;
const mockSurvey = {
id: "survey_test_id",
name: "Test Survey",
type: "app",
environmentId: "env_test_id",
status: "inProgress",
questions: [],
hiddenFields: { enabled: false },
displayOption: "displayOnce",
autoClose: null,
triggers: [],
languages: [],
resultShareKey: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
closeOnDate: null,
delay: 0,
displayPercentage: null,
recontactDays: null,
autoComplete: null,
runOnDate: null,
segment: null,
variables: [],
} as unknown as TSurvey;
const mockSelectedFilter = { filter: [], onlyComplete: false };
const mockSetSelectedFilter = vi.fn();
const defaultProps = {
summary: [] as TSurveySummary["summary"],
responseCount: 10,
environment: mockEnvironment,
survey: mockSurvey,
totalResponseCount: 20,
locale: "en" as TUserLocale,
};
const createMockQuestionSummary = (
id: string,
type: TSurveyQuestionTypeEnum,
headline: string = "Test Question"
) =>
({
question: {
id,
headline: { default: headline, en: headline },
type,
required: false,
choices:
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
? [{ id: "choice1", label: { default: "Choice 1" } }]
: undefined,
logic: [],
},
type,
responseCount: 5,
samples: type === TSurveyQuestionTypeEnum.OpenText ? [{ value: "sample" }] : [],
choices:
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
? [{ label: { default: "Choice 1" }, count: 5, percentage: 1 }]
: [],
dismissed:
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
? { count: 0, percentage: 0 }
: undefined,
others:
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
? [{ value: "other", count: 0, percentage: 0 }]
: [],
progress: type === TSurveyQuestionTypeEnum.NPS ? { total: 5, trend: 0.5 } : undefined,
average: type === TSurveyQuestionTypeEnum.Rating ? 3.5 : undefined,
accepted: type === TSurveyQuestionTypeEnum.Consent ? { count: 5, percentage: 1 } : undefined,
results:
type === TSurveyQuestionTypeEnum.PictureSelection
? [{ imageUrl: "url", count: 5, percentage: 1 }]
: undefined,
files: type === TSurveyQuestionTypeEnum.FileUpload ? [{ url: "url", name: "file.pdf", size: 100 }] : [],
booked: type === TSurveyQuestionTypeEnum.Cal ? { count: 5, percentage: 1 } : undefined,
data: type === TSurveyQuestionTypeEnum.Matrix ? [{ rowLabel: "Row1", responses: {} }] : undefined,
ranking: type === TSurveyQuestionTypeEnum.Ranking ? [{ rank: 1, choiceLabel: "Choice1", count: 5 }] : [],
}) as unknown as TSurveySummary["summary"][number];
const createMockHiddenFieldSummary = (id: string, label: string = "Hidden Field") =>
({
id,
type: "hiddenField",
label,
value: "some value",
count: 1,
samples: [{ personId: "person1", value: "Sample Value", updatedAt: new Date().toISOString() }],
responseCount: 1,
}) as unknown as TSurveySummary["summary"][number];
const typeToComponentMockNameMap: Record<TSurveyQuestionTypeEnum, string> = {
[TSurveyQuestionTypeEnum.OpenText]: "OpenTextSummary",
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: "MultipleChoiceSummary",
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: "MultipleChoiceSummary",
[TSurveyQuestionTypeEnum.NPS]: "NPSSummary",
[TSurveyQuestionTypeEnum.CTA]: "CTASummary",
[TSurveyQuestionTypeEnum.Rating]: "RatingSummary",
[TSurveyQuestionTypeEnum.Consent]: "ConsentSummary",
[TSurveyQuestionTypeEnum.PictureSelection]: "PictureChoiceSummary",
[TSurveyQuestionTypeEnum.Date]: "DateQuestionSummary",
[TSurveyQuestionTypeEnum.FileUpload]: "FileUploadSummary",
[TSurveyQuestionTypeEnum.Cal]: "CalSummary",
[TSurveyQuestionTypeEnum.Matrix]: "MatrixQuestionSummary",
[TSurveyQuestionTypeEnum.Address]: "AddressSummary",
[TSurveyQuestionTypeEnum.Ranking]: "RankingSummary",
[TSurveyQuestionTypeEnum.ContactInfo]: "ContactInfoSummary",
};
describe("SummaryList", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: mockSelectedFilter,
setSelectedFilter: mockSetSelectedFilter,
resetFilter: vi.fn(),
} as any);
});
test("renders EmptyAppSurveys when survey type is app, responseCount is 0 and appSetupCompleted is false", () => {
const testEnv = { ...mockEnvironment, appSetupCompleted: false };
const testSurvey = { ...mockSurvey, type: "app" as const };
render(<SummaryList {...defaultProps} survey={testSurvey} responseCount={0} environment={testEnv} />);
expect(screen.getByText("Mocked EmptyAppSurveys")).toBeInTheDocument();
});
test("renders SkeletonLoader when summary is empty and responseCount is not 0", () => {
render(<SummaryList {...defaultProps} summary={[]} responseCount={1} />);
expect(screen.getByText("Mocked SkeletonLoader")).toBeInTheDocument();
});
test("renders EmptySpaceFiller when responseCount is 0 and summary is not empty (no responses match filter)", () => {
const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)];
render(
<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} totalResponseCount={10} />
);
expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument();
});
test("renders EmptySpaceFiller when responseCount is 0 and totalResponseCount is 0 (no responses at all)", () => {
const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)];
render(
<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} totalResponseCount={0} />
);
expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument();
});
const questionTypesToTest: TSurveyQuestionTypeEnum[] = [
TSurveyQuestionTypeEnum.OpenText,
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
TSurveyQuestionTypeEnum.NPS,
TSurveyQuestionTypeEnum.CTA,
TSurveyQuestionTypeEnum.Rating,
TSurveyQuestionTypeEnum.Consent,
TSurveyQuestionTypeEnum.PictureSelection,
TSurveyQuestionTypeEnum.Date,
TSurveyQuestionTypeEnum.FileUpload,
TSurveyQuestionTypeEnum.Cal,
TSurveyQuestionTypeEnum.Matrix,
TSurveyQuestionTypeEnum.Address,
TSurveyQuestionTypeEnum.Ranking,
TSurveyQuestionTypeEnum.ContactInfo,
];
questionTypesToTest.forEach((type) => {
test(`renders ${type}Summary component`, () => {
const mockSummaryItem = createMockQuestionSummary(`q_${type}`, type);
const expectedComponentName = typeToComponentMockNameMap[type];
render(<SummaryList {...defaultProps} summary={[mockSummaryItem]} />);
expect(
screen.getByText(new RegExp(`Mocked ${expectedComponentName}:\\s*q_${type}`))
).toBeInTheDocument();
});
});
test("renders HiddenFieldsSummary component", () => {
const mockSummaryItem = createMockHiddenFieldSummary("hf1");
render(<SummaryList {...defaultProps} summary={[mockSummaryItem]} />);
expect(screen.getByText("Mocked HiddenFieldsSummary: hf1")).toBeInTheDocument();
});
describe("setFilter function", () => {
const questionId = "q_mc_single";
const label: TI18nString = { default: "MC Single Question" };
const questionType = TSurveyQuestionTypeEnum.MultipleChoiceSingle;
const filterValue = "Choice 1";
const filterComboBoxValue = "choice1_id";
beforeEach(() => {
// Render with a component that uses setFilter, e.g., MultipleChoiceSummary
const mockSummaryItem = createMockQuestionSummary(questionId, questionType, label.default);
render(<SummaryList {...defaultProps} summary={[mockSummaryItem]} />);
});
const getSetFilterFn = () => {
const MultipleChoiceSummaryMock = vi.mocked(MultipleChoiceSummary);
return MultipleChoiceSummaryMock.mock.calls[0][0].setFilter;
};
test("adds a new filter", () => {
const setFilter = getSetFilterFn();
vi.mocked(constructToastMessage).mockReturnValue("Custom add message");
setFilter(questionId, label, questionType, filterValue, filterComboBoxValue);
expect(mockSetSelectedFilter).toHaveBeenCalledWith({
filter: [
{
questionType: {
id: questionId,
label: label.default,
questionType: questionType,
type: OptionsType.QUESTIONS,
},
filterType: {
filterComboBoxValue: filterComboBoxValue,
filterValue: filterValue,
},
},
],
onlyComplete: false,
});
// Ensure vi.mocked(toast.success) refers to the spy from the named export
expect(vi.mocked(toast).success).toHaveBeenCalledWith("Custom add message", { duration: 5000 });
expect(vi.mocked(constructToastMessage)).toHaveBeenCalledWith(
questionType,
filterValue,
mockSurvey,
questionId,
expect.any(Function), // t function
filterComboBoxValue
);
});
test("updates an existing filter", () => {
const existingFilter = {
questionType: {
id: questionId,
label: label.default,
questionType: questionType,
type: OptionsType.QUESTIONS,
},
filterType: {
filterComboBoxValue: "old_value_combo",
filterValue: "old_value",
},
};
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [existingFilter], onlyComplete: false },
setSelectedFilter: mockSetSelectedFilter,
resetFilter: vi.fn(),
} as any);
// Re-render or get setFilter again as selectedFilter changed
cleanup();
const mockSummaryItem = createMockQuestionSummary(questionId, questionType, label.default);
render(<SummaryList {...defaultProps} summary={[mockSummaryItem]} />);
const setFilter = getSetFilterFn();
const newFilterValue = "New Choice";
const newFilterComboBoxValue = "new_choice_id";
setFilter(questionId, label, questionType, newFilterValue, newFilterComboBoxValue);
expect(mockSetSelectedFilter).toHaveBeenCalledWith({
filter: [
{
questionType: {
id: questionId,
label: label.default,
questionType: questionType,
type: OptionsType.QUESTIONS,
},
filterType: {
filterComboBoxValue: newFilterComboBoxValue,
filterValue: newFilterValue,
},
},
],
onlyComplete: false,
});
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
"environments.surveys.summary.filter_updated_successfully",
{
duration: 5000,
}
);
});
});
});

View File

@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { SurveyAnalysisCTA } from "../SurveyAnalysisCTA";
import { SurveyAnalysisCTA } from "./SurveyAnalysisCTA";
// Mock constants
vi.mock("@/lib/constants", () => ({

View File

@@ -0,0 +1,63 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AppTab } from "./AppTab";
vi.mock("@/modules/ui/components/options-switch", () => ({
OptionsSwitch: (props: {
options: Array<{ value: string; label: string }>;
handleOptionChange: (value: string) => void;
}) => (
<div data-testid="options-switch">
{props.options.map((option) => (
<button
key={option.value}
data-testid={`option-${option.value}`}
onClick={() => props.handleOptionChange(option.value)}>
{option.label}
</button>
))}
</div>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab",
() => ({
MobileAppTab: () => <div data-testid="mobile-app-tab">MobileAppTab</div>,
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab",
() => ({
WebAppTab: () => <div data-testid="web-app-tab">WebAppTab</div>,
})
);
describe("AppTab", () => {
afterEach(() => {
cleanup();
});
test("renders correctly by default with WebAppTab visible", () => {
render(<AppTab />);
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
expect(screen.getByTestId("option-webapp")).toBeInTheDocument();
expect(screen.getByTestId("option-mobile")).toBeInTheDocument();
expect(screen.getByTestId("web-app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("mobile-app-tab")).not.toBeInTheDocument();
});
test("switches to MobileAppTab when mobile option is selected", async () => {
const user = userEvent.setup();
render(<AppTab />);
const mobileOptionButton = screen.getByTestId("option-mobile");
await user.click(mobileOptionButton);
expect(screen.getByTestId("mobile-app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("web-app-tab")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,233 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
import { EmailTab } from "./EmailTab";
// Mock actions
vi.mock("../../actions", () => ({
getEmailHtmlAction: vi.fn(),
sendEmbedSurveyPreviewEmailAction: vi.fn(),
}));
// Mock helper
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((val) => val?.serverError || "Formatted error message"),
}));
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, title, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children, language }: { children: React.ReactNode; language: string }) => (
<div data-testid="code-block" data-language={language}>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/loading-spinner", () => ({
LoadingSpinner: () => <div data-testid="loading-spinner">LoadingSpinner</div>,
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Code2Icon: () => <div data-testid="code2-icon" />,
CopyIcon: () => <div data-testid="copy-icon" />,
MailIcon: () => <div data-testid="mail-icon" />,
}));
// Mock navigator.clipboard
const mockWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
},
configurable: true,
});
const surveyId = "test-survey-id";
const userEmail = "test@example.com";
const mockEmailHtmlPreview = "<p>Hello World ?preview=true&amp;foo=bar</p>";
const mockCleanedEmailHtml = "<p>Hello World ?foo=bar</p>";
describe("EmailTab", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getEmailHtmlAction).mockResolvedValue({ data: mockEmailHtmlPreview });
});
afterEach(() => {
cleanup();
});
test("renders initial state correctly and fetches email HTML", async () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalledWith({ surveyId });
// Buttons
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
).toBeInTheDocument();
expect(screen.getByTestId("mail-icon")).toBeInTheDocument();
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
// Email preview section
await waitFor(() => {
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
});
expect(
screen.getByText("Subject : environments.surveys.summary.formbricks_email_survey_preview")
).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Hello World ?preview=true&foo=bar")).toBeInTheDocument(); // Raw HTML content
});
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
test("toggles embed code view", async () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
});
await userEvent.click(viewEmbedButton);
// Embed code view
expect(screen.getByRole("button", { name: "Embed survey in your website" })).toBeInTheDocument(); // Updated name
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" }) // Updated name for hide button
).toBeInTheDocument();
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveTextContent(mockCleanedEmailHtml); // Cleaned HTML
expect(screen.queryByText(`To : ${userEmail}`)).not.toBeInTheDocument();
// Toggle back
const hideEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email", // Updated name for hide button
});
await userEvent.click(hideEmbedButton);
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
).toBeInTheDocument();
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
test("copies code to clipboard", async () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
});
await userEvent.click(viewEmbedButton);
// Ensure this line queries by the correct aria-label
const copyCodeButton = screen.getByRole("button", { name: "Embed survey in your website" });
await userEvent.click(copyCodeButton);
expect(mockWriteText).toHaveBeenCalledWith(mockCleanedEmailHtml);
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.embed_code_copied_to_clipboard");
});
test("sends preview email successfully", async () => {
vi.mocked(sendEmbedSurveyPreviewEmailAction).mockResolvedValue({ data: true });
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.email_sent");
});
test("handles send preview email failure (server error)", async () => {
const errorResponse = { serverError: "Server issue" };
vi.mocked(sendEmbedSurveyPreviewEmailAction).mockResolvedValue(errorResponse as any);
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
expect(getFormattedErrorMessage).toHaveBeenCalledWith(errorResponse);
expect(toast.error).toHaveBeenCalledWith("Server issue");
});
test("handles send preview email failure (authentication error)", async () => {
vi.mocked(sendEmbedSurveyPreviewEmailAction).mockRejectedValue(new AuthenticationError("Auth failed"));
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("common.not_authenticated");
});
});
test("handles send preview email failure (generic error)", async () => {
vi.mocked(sendEmbedSurveyPreviewEmailAction).mockRejectedValue(new Error("Generic error"));
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
});
});
test("renders loading spinner if email HTML is not yet fetched", () => {
vi.mocked(getEmailHtmlAction).mockReturnValue(new Promise(() => {})); // Never resolves
render(<EmailTab surveyId={surveyId} email={userEmail} />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
test("renders default email if email prop is not provided", async () => {
render(<EmailTab surveyId={surveyId} email="" />);
await waitFor(() => {
expect(screen.getByText("To : user@mail.com")).toBeInTheDocument();
});
});
test("emailHtml memo removes various ?preview=true patterns", async () => {
const htmlWithVariants =
"<p>Test1 ?preview=true</p><p>Test2 ?preview=true&amp;next</p><p>Test3 ?preview=true&;next</p>";
// Ensure this line matches the "Received" output from your test error
const expectedCleanHtml = "<p>Test1 </p><p>Test2 ?next</p><p>Test3 ?next</p>";
vi.mocked(getEmailHtmlAction).mockResolvedValue({ data: htmlWithVariants });
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
});
await userEvent.click(viewEmbedButton);
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toHaveTextContent(expectedCleanHtml);
});
});

View File

@@ -0,0 +1,154 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EmbedView } from "./EmbedView";
// Mock child components
vi.mock("./AppTab", () => ({
AppTab: () => <div data-testid="app-tab">AppTab Content</div>,
}));
vi.mock("./EmailTab", () => ({
EmailTab: (props: { surveyId: string; email: string }) => (
<div data-testid="email-tab">
EmailTab Content for {props.surveyId} with {props.email}
</div>
),
}));
vi.mock("./LinkTab", () => ({
LinkTab: (props: { survey: any; surveyUrl: string }) => (
<div data-testid="link-tab">
LinkTab Content for {props.survey.id} at {props.surveyUrl}
</div>
),
}));
vi.mock("./WebsiteTab", () => ({
WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => (
<div data-testid="website-tab">
WebsiteTab Content for {props.surveyUrl} in {props.environmentId}
</div>
),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
MailIcon: () => <div data-testid="mail-icon">MailIcon</div>,
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
}));
const mockTabs = [
{ id: "email", label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
{ id: "webpage", label: "Web Page", icon: () => <div data-testid="webpage-tab-icon" /> },
{ id: "link", label: "Link", icon: () => <div data-testid="link-tab-icon" /> },
{ id: "app", label: "App", icon: () => <div data-testid="app-tab-icon" /> },
];
const mockSurveyLink = { id: "survey1", type: "link" };
const mockSurveyWeb = { id: "survey2", type: "web" };
const defaultProps = {
handleInitialPageButton: vi.fn(),
tabs: mockTabs,
activeId: "email",
setActiveId: vi.fn(),
environmentId: "env1",
survey: mockSurveyLink,
email: "test@example.com",
surveyUrl: "http://example.com/survey1",
surveyDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
disableBack: false,
};
describe("EmbedView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("does not render back button when disableBack is true", () => {
render(<EmbedView {...defaultProps} disableBack={true} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
});
test("does not render desktop tabs for non-link survey type", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
// Desktop tabs container should not be present or not have lg:flex if it's a common parent
const desktopTabsButtons = screen.queryAllByRole("button", { name: /Email|Web Page|Link|App/i });
// Check if any of these buttons are part of a container that is only visible on large screens
const desktopTabContainer = desktopTabsButtons[0]?.closest("div.lg\\:flex");
expect(desktopTabContainer).toBeNull();
});
test("calls setActiveId when a tab is clicked (desktop)", async () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0]; // First one is desktop
await userEvent.click(webpageTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
});
test("renders EmailTab when activeId is 'email'", () => {
render(<EmbedView {...defaultProps} activeId="email" />);
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
expect(
screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`)
).toBeInTheDocument();
});
test("renders WebsiteTab when activeId is 'webpage'", () => {
render(<EmbedView {...defaultProps} activeId="webpage" />);
expect(screen.getByTestId("website-tab")).toBeInTheDocument();
expect(
screen.getByText(`WebsiteTab Content for ${defaultProps.surveyUrl} in ${defaultProps.environmentId}`)
).toBeInTheDocument();
});
test("renders LinkTab when activeId is 'link'", () => {
render(<EmbedView {...defaultProps} activeId="link" />);
expect(screen.getByTestId("link-tab")).toBeInTheDocument();
expect(
screen.getByText(`LinkTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
).toBeInTheDocument();
});
test("renders AppTab when activeId is 'app'", () => {
render(<EmbedView {...defaultProps} activeId="app" />);
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
// Get the responsive tab button (second instance of the button with this name)
const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1];
await userEvent.click(responsiveWebpageTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
});
test("applies active styles to the active tab (desktop)", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const emailTabButton = screen.getAllByRole("button", { name: "Email" })[0];
expect(emailTabButton).toHaveClass("border-slate-200 bg-slate-100 font-semibold text-slate-900");
const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0];
expect(webpageTabButton).toHaveClass("border-transparent text-slate-500 hover:text-slate-700");
});
test("applies active styles to the active tab (responsive)", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const responsiveEmailTabButton = screen.getAllByRole("button", { name: "Email" })[1];
expect(responsiveEmailTabButton).toHaveClass("bg-white text-slate-900 shadow-sm");
const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1];
expect(responsiveWebpageTabButton).toHaveClass("border-transparent text-slate-700 hover:text-slate-900");
});
});

View File

@@ -0,0 +1,155 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LinkTab } from "./LinkTab";
// Mock ShareSurveyLink
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(({ survey, surveyUrl, surveyDomain, locale }) => (
<div data-testid="share-survey-link">
Mocked ShareSurveyLink
<span data-testid="survey-id">{survey.id}</span>
<span data-testid="survey-url">{surveyUrl}</span>
<span data-testid="survey-domain">{surveyDomain}</span>
<span data-testid="locale">{locale}</span>
</div>
)),
}));
// Mock useTranslate
const mockTranslate = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockTranslate,
}),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
const mockSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
status: "inProgress",
questions: [],
thankYouCard: { enabled: false },
endings: [],
autoClose: null,
triggers: [],
languages: [],
styling: null,
} as unknown as TSurvey;
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockSurveyDomain = "https://app.formbricks.com";
const mockSetSurveyUrl = vi.fn();
const mockLocale: TUserLocale = "en-US";
const docsLinksExpected = [
{
titleKey: "environments.surveys.summary.data_prefilling",
descriptionKey: "environments.surveys.summary.data_prefilling_description",
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
},
{
titleKey: "environments.surveys.summary.source_tracking",
descriptionKey: "environments.surveys.summary.source_tracking_description",
link: "https://formbricks.com/docs/link-surveys/source-tracking",
},
{
titleKey: "environments.surveys.summary.create_single_use_links",
descriptionKey: "environments.surveys.summary.create_single_use_links_description",
link: "https://formbricks.com/docs/link-surveys/single-use-links",
},
];
describe("LinkTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the main title", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(
screen.getByText("environments.surveys.summary.share_the_link_to_get_responses")
).toBeInTheDocument();
});
test("renders ShareSurveyLink with correct props", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
expect(screen.getByTestId("survey-domain")).toHaveTextContent(mockSurveyDomain);
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
});
test("renders the promotional text for link surveys", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(
screen.getByText("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys 💡")
).toBeInTheDocument();
});
test("renders all documentation links correctly", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
docsLinksExpected.forEach((doc) => {
const linkElement = screen.getByText(doc.titleKey).closest("a");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", doc.link);
expect(linkElement).toHaveAttribute("target", "_blank");
expect(screen.getByText(doc.descriptionKey)).toBeInTheDocument();
});
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling_description");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking_description");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.create_single_use_links");
expect(mockTranslate).toHaveBeenCalledWith(
"environments.surveys.summary.create_single_use_links_description"
);
});
});

View File

@@ -0,0 +1,69 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { MobileAppTab } from "./MobileAppTab";
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key, // Return the key itself for easy assertion
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: { children: React.ReactNode }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-description">{children}</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, asChild, ...props }: { children: React.ReactNode; asChild?: boolean }) =>
asChild ? <div {...props}>{children}</div> : <button {...props}>{children}</button>,
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target, ...props }: any) => (
<a href={href} target={target} {...props}>
{children}
</a>
),
}));
describe("MobileAppTab", () => {
afterEach(() => {
cleanup();
});
test("renders correctly with title, description, and learn more link", () => {
render(<MobileAppTab />);
// Check for Alert component
expect(screen.getByTestId("alert")).toBeInTheDocument();
// Check for AlertTitle with correct Tolgee key
const alertTitle = screen.getByTestId("alert-title");
expect(alertTitle).toBeInTheDocument();
expect(alertTitle).toHaveTextContent("environments.surveys.summary.quickstart_mobile_apps");
// Check for AlertDescription with correct Tolgee key
const alertDescription = screen.getByTestId("alert-description");
expect(alertDescription).toBeInTheDocument();
expect(alertDescription).toHaveTextContent(
"environments.surveys.summary.quickstart_mobile_apps_description"
);
// Check for the "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
});
});

View File

@@ -0,0 +1,108 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PanelInfoView } from "./PanelInfoView";
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, alt, className }: { src: any; alt: string; className?: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src.src} alt={alt} className={className} />
),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target}>
{children}
</a>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, asChild }: any) => {
if (asChild) {
return <div onClick={onClick}>{children}</div>; // NOSONAR
}
return (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
);
},
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: vi.fn(() => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>),
}));
const mockHandleInitialPageButton = vi.fn();
describe("PanelInfoView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with back button and all sections", async () => {
render(<PanelInfoView disableBack={false} handleInitialPageButton={mockHandleInitialPageButton} />);
// Check for back button
const backButton = screen.getByText("common.back");
expect(backButton).toBeInTheDocument();
expect(screen.getByTestId("arrow-left-icon")).toBeInTheDocument();
// Check images
expect(screen.getAllByAltText("Prolific panel selection UI")[0]).toBeInTheDocument();
expect(screen.getAllByAltText("Prolific panel selection UI")[1]).toBeInTheDocument();
// Check text content (Tolgee keys)
expect(screen.getByText("environments.surveys.summary.what_is_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_a_panel_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4_description")
).toBeInTheDocument();
// Check "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
// Click back button
await userEvent.click(backButton);
expect(mockHandleInitialPageButton).toHaveBeenCalledTimes(1);
});
test("renders correctly without back button when disableBack is true", () => {
render(<PanelInfoView disableBack={true} handleInitialPageButton={mockHandleInitialPageButton} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
expect(screen.queryByTestId("arrow-left-icon")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebAppTab } from "./WebAppTab";
vi.mock("@/modules/ui/components/button/Button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon" />,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: { children: React.ReactNode }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-description">{children}</div>
),
}));
// Mock navigator.clipboard.writeText
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
configurable: true,
});
const surveyUrl = "https://app.formbricks.com/s/test-survey-id";
const surveyId = "test-survey-id";
describe("WebAppTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with surveyUrl and surveyId", () => {
render(<WebAppTab />);
expect(screen.getByText("environments.surveys.summary.quickstart_web_apps")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.learn_more" })).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/quickstart"
);
});
});

View File

@@ -0,0 +1,254 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { WebsiteTab } from "./WebsiteTab";
// Mock child components and hooks
const mockAdvancedOptionToggle = vi.fn();
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: (props: any) => {
mockAdvancedOptionToggle(props);
return (
<div data-testid="advanced-option-toggle">
<span>{props.title}</span>
<input type="checkbox" checked={props.isChecked} onChange={() => props.onToggle(!props.isChecked)} />
</div>
);
},
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
const mockCodeBlock = vi.fn();
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: (props: any) => {
mockCodeBlock(props);
return (
<div data-testid="code-block" data-language={props.language}>
{props.children}
</div>
);
},
}));
const mockOptionsSwitch = vi.fn();
vi.mock("@/modules/ui/components/options-switch", () => ({
OptionsSwitch: (props: any) => {
mockOptionsSwitch(props);
return (
<div data-testid="options-switch">
{props.options.map((opt: { value: string; label: string }) => (
<button key={opt.value} onClick={() => props.handleOptionChange(opt.value)}>
{opt.label}
</button>
))}
</div>
);
},
}));
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target} data-testid="next-link">
{children}
</a>
),
}));
const mockWriteText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
},
configurable: true,
});
const surveyUrl = "https://app.formbricks.com/s/survey123";
const environmentId = "env456";
describe("WebsiteTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders OptionsSwitch and StaticTab by default", () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
expect(mockOptionsSwitch).toHaveBeenCalledWith(
expect.objectContaining({
currentOption: "static",
options: [
{ value: "static", label: "environments.surveys.summary.static_iframe" },
{ value: "popup", label: "environments.surveys.summary.dynamic_popup" },
],
})
);
// StaticTab content checks
expect(screen.getByText("common.copy_code")).toBeInTheDocument();
expect(screen.getByTestId("code-block")).toBeInTheDocument();
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.static_iframe")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.dynamic_popup")).toBeInTheDocument();
});
test("switches to PopupTab when 'Dynamic Popup' option is clicked", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const popupButton = screen.getByRole("button", {
name: "environments.surveys.summary.dynamic_popup",
});
await userEvent.click(popupButton);
expect(mockOptionsSwitch.mock.calls.some((call) => call[0].currentOption === "popup")).toBe(true);
// PopupTab content checks
expect(screen.getByText("environments.surveys.summary.embed_pop_up_survey_title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_instructions")).toBeInTheDocument();
expect(screen.getByRole("list")).toBeInTheDocument(); // Check for the ol element
const listItems = screen.getAllByRole("listitem");
expect(listItems[0]).toHaveTextContent(
"common.follow_these environments.surveys.summary.setup_instructions environments.surveys.summary.to_connect_your_website_with_formbricks"
);
expect(listItems[1]).toHaveTextContent(
"environments.surveys.summary.make_sure_the_survey_type_is_set_to common.website_survey"
);
expect(listItems[2]).toHaveTextContent(
"environments.surveys.summary.define_when_and_where_the_survey_should_pop_up"
);
expect(
screen.getByRole("link", { name: "environments.surveys.summary.setup_instructions" })
).toHaveAttribute("href", `/environments/${environmentId}/project/website-connection`);
expect(
screen.getByText("environments.surveys.summary.unsupported_video_tag_warning").closest("video")
).toBeInTheDocument();
});
describe("StaticTab", () => {
const formattedBaseCode = `<div style="position: relative; height:80dvh; overflow:auto;"> \n <iframe \n src="${surveyUrl}" \n frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">\n </iframe>\n</div>`;
const normalizedBaseCode = `<div style="position: relative; height:80dvh; overflow:auto;"> <iframe src="${surveyUrl}" frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;"> </iframe> </div>`;
const formattedEmbedCode = `<div style="position: relative; height:80dvh; overflow:auto;"> \n <iframe \n src="${surveyUrl}?embed=true" \n frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">\n </iframe>\n</div>`;
const normalizedEmbedCode = `<div style="position: relative; height:80dvh; overflow:auto;"> <iframe src="${surveyUrl}?embed=true" frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;"> </iframe> </div>`;
test("renders correctly with initial iframe code and embed mode toggle", () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />); // Defaults to StaticTab
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedBaseCode);
expect(mockCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({ children: formattedBaseCode, language: "html" })
);
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
expect(mockAdvancedOptionToggle).toHaveBeenCalledWith(
expect.objectContaining({
isChecked: false,
title: "environments.surveys.summary.embed_mode",
description: "environments.surveys.summary.embed_mode_description",
})
);
expect(screen.getByText("environments.surveys.summary.embed_mode")).toBeInTheDocument();
});
test("copies iframe code to clipboard when 'Copy Code' is clicked", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const copyButton = screen.getByRole("button", { name: "Embed survey in your website" });
await userEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledWith(formattedBaseCode);
expect(toast.success).toHaveBeenCalledWith(
"environments.surveys.summary.embed_code_copied_to_clipboard"
);
expect(screen.getByText("common.copy_code")).toBeInTheDocument();
});
test("updates iframe code when 'Embed Mode' is toggled", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const embedToggle = screen
.getByTestId("advanced-option-toggle")
.querySelector('input[type="checkbox"]');
expect(embedToggle).not.toBeNull();
await userEvent.click(embedToggle!);
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedEmbedCode);
expect(mockCodeBlock.mock.calls.find((call) => call[0].children === formattedEmbedCode)).toBeTruthy();
expect(mockAdvancedOptionToggle.mock.calls.some((call) => call[0].isChecked === true)).toBe(true);
// Toggle back
await userEvent.click(embedToggle!);
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedBaseCode);
expect(mockCodeBlock.mock.calls.find((call) => call[0].children === formattedBaseCode)).toBeTruthy();
expect(mockAdvancedOptionToggle.mock.calls.some((call) => call[0].isChecked === false)).toBe(true);
});
});
describe("PopupTab", () => {
beforeEach(async () => {
// Ensure PopupTab is active
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const popupButton = screen.getByRole("button", {
name: "environments.surveys.summary.dynamic_popup",
});
await userEvent.click(popupButton);
});
test("renders title and instructions", () => {
expect(screen.getByText("environments.surveys.summary.embed_pop_up_survey_title")).toBeInTheDocument();
const listItems = screen.getAllByRole("listitem");
expect(listItems).toHaveLength(3);
expect(listItems[0]).toHaveTextContent(
"common.follow_these environments.surveys.summary.setup_instructions environments.surveys.summary.to_connect_your_website_with_formbricks"
);
expect(listItems[1]).toHaveTextContent(
"environments.surveys.summary.make_sure_the_survey_type_is_set_to common.website_survey"
);
expect(listItems[2]).toHaveTextContent(
"environments.surveys.summary.define_when_and_where_the_survey_should_pop_up"
);
// Specific checks for elements or distinct text content
expect(screen.getByText("environments.surveys.summary.setup_instructions")).toBeInTheDocument(); // Checks the link text
expect(screen.getByText("common.website_survey")).toBeInTheDocument(); // Checks the bold text
// The text for the last list item is its sole content, so getByText works here.
expect(
screen.getByText("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")
).toBeInTheDocument();
});
test("renders the setup instructions link with correct href", () => {
const link = screen.getByRole("link", { name: "environments.surveys.summary.setup_instructions" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", `/environments/${environmentId}/project/website-connection`);
expect(link).toHaveAttribute("target", "_blank");
});
test("renders the video", () => {
const videoElement = screen
.getByText("environments.surveys.summary.unsupported_video_tag_warning")
.closest("video");
expect(videoElement).toBeInTheDocument();
expect(videoElement).toHaveAttribute("autoPlay");
expect(videoElement).toHaveAttribute("loop");
const sourceElement = videoElement?.querySelector("source");
expect(sourceElement).toHaveAttribute("src", "/video/tooltips/change-survey-type.mp4");
expect(sourceElement).toHaveAttribute("type", "video/mp4");
expect(
screen.getByText("environments.surveys.summary.unsupported_video_tag_warning")
).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,170 @@
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { getTranslate } from "@/tolgee/server";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getEmailTemplateHtml } from "./emailTemplate";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/getSurveyUrl");
vi.mock("@/lib/project/service");
vi.mock("@/lib/survey/service");
vi.mock("@/lib/utils/styling");
vi.mock("@/modules/email/components/preview-email-template");
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
const mockSurveyId = "survey123";
const mockLocale = "en";
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const mockSurvey = {
id: mockSurveyId,
name: "Test Survey",
environmentId: "env456",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question?" },
} as unknown as TSurveyQuestion,
],
styling: null,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
displayPercentage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
surveyClosedMessage: null,
singleUse: null,
resultShareKey: null,
variables: [],
segment: null,
autoClose: null,
delay: 0,
autoComplete: null,
runOnDate: null,
closeOnDate: null,
} as unknown as TSurvey;
const mockProject = {
id: "proj789",
name: "Test Project",
environments: [{ id: "env456", type: "production" } as unknown as TEnvironment],
styling: {
allowStyleOverwrite: true,
brandColor: { light: "#007BFF", dark: "#007BFF" },
highlightBorderColor: null,
cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" },
cardBorderColor: { light: "#FFFFFF", dark: "#000000" },
cardShadowColor: { light: "#FFFFFF", dark: "#000000" },
questionColor: { light: "#FFFFFF", dark: "#000000" },
inputColor: { light: "#FFFFFF", dark: "#000000" },
inputBorderColor: { light: "#FFFFFF", dark: "#000000" },
},
createdAt: new Date(),
updatedAt: new Date(),
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
recontactDays: 30,
logo: null,
} as unknown as TProject;
const mockComputedStyling = {
brandColor: "#007BFF",
questionColor: "#000000",
inputColor: "#000000",
inputBorderColor: "#000000",
cardBackgroundColor: "#FFFFFF",
cardBorderColor: "#EEEEEE",
cardShadowColor: "#AAAAAA",
highlightBorderColor: null,
thankYouCardIconColor: "#007BFF",
thankYouCardIconBgColor: "#DDDDDD",
} as any;
const mockSurveyDomain = "https://app.formbricks.com";
const mockRawHtml = `${doctype}<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
const mockCleanedHtml = `<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
describe("getEmailTemplateHtml", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(getStyling).mockReturnValue(mockComputedStyling);
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
vi.mocked(getPreviewEmailTemplateHtml).mockResolvedValue(mockRawHtml);
});
test("should return cleaned HTML when all services provide data", async () => {
const html = await getEmailTemplateHtml(mockSurveyId, mockLocale);
expect(html).toBe(mockCleanedHtml);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockSurvey.environmentId);
expect(getStyling).toHaveBeenCalledWith(mockProject, mockSurvey);
expect(getSurveyDomain).toHaveBeenCalledTimes(1);
const expectedSurveyUrl = `${mockSurveyDomain}/s/${mockSurvey.id}`;
expect(getPreviewEmailTemplateHtml).toHaveBeenCalledWith(
mockSurvey,
expectedSurveyUrl,
mockComputedStyling,
mockLocale,
expect.any(Function)
);
});
test("should throw error if survey is not found", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
await expect(getEmailTemplateHtml(mockSurveyId, mockLocale)).rejects.toThrow("Survey not found");
});
test("should throw error if project is not found", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
await expect(getEmailTemplateHtml(mockSurveyId, mockLocale)).rejects.toThrow("Project not found");
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { getQRCodeOptions } from "./get-qr-code-options";
describe("getQRCodeOptions", () => {
test("should return correct QR code options for given width and height", () => {
const width = 300;
const height = 300;
const options = getQRCodeOptions(width, height);
expect(options).toEqual({
width,
height,
type: "svg",
data: "",
margin: 0,
qrOptions: {
typeNumber: 0,
mode: "Byte",
errorCorrectionLevel: "L",
},
imageOptions: {
saveAsBlob: true,
hideBackgroundDots: false,
imageSize: 0,
margin: 0,
},
dotsOptions: {
type: "extra-rounded",
color: "#000000",
roundSize: true,
},
backgroundOptions: {
color: "#ffffff",
},
cornersSquareOptions: {
type: "dot",
color: "#000000",
},
cornersDotOptions: {
type: "dot",
color: "#000000",
},
});
});
test("should return correct QR code options for different width and height", () => {
const width = 150;
const height = 200;
const options = getQRCodeOptions(width, height);
expect(options.width).toBe(width);
expect(options.height).toBe(height);
expect(options.type).toBe("svg");
// Check a few other properties to ensure the structure is consistent
expect(options.dotsOptions?.type).toBe("extra-rounded");
expect(options.backgroundOptions?.color).toBe("#ffffff");
});
});

View File

@@ -0,0 +1,96 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import QRCodeStyling from "qr-code-styling";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { useSurveyQRCode } from "./survey-qr-code";
// Mock QRCodeStyling
const mockUpdate = vi.fn();
const mockAppend = vi.fn();
const mockDownload = vi.fn();
vi.mock("qr-code-styling", () => {
return {
default: vi.fn().mockImplementation(() => ({
update: mockUpdate,
append: mockAppend,
download: mockDownload,
})),
};
});
describe("useSurveyQRCode", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset the DOM element for qrCodeRef before each test
if (document.body.querySelector("#qr-code-test-div")) {
document.body.removeChild(document.body.querySelector("#qr-code-test-div")!);
}
const div = document.createElement("div");
div.id = "qr-code-test-div";
document.body.appendChild(div);
});
test("should call toast.error if QRCodeStyling instantiation fails", () => {
vi.mocked(QRCodeStyling).mockImplementationOnce(() => {
throw new Error("QR Init failed");
});
renderHook(() => useSurveyQRCode("https://example.com/survey-error"));
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_generate_qr_code");
});
test("should call toast.error if QRCodeStyling update fails", () => {
mockUpdate.mockImplementationOnce(() => {
throw new Error("QR Update failed");
});
renderHook(() => useSurveyQRCode("https://example.com/survey-update-error"));
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_generate_qr_code");
});
test("should call toast.error if QRCodeStyling append fails", () => {
mockAppend.mockImplementationOnce(() => {
throw new Error("QR Append failed");
});
const { result } = renderHook(() => useSurveyQRCode("https://example.com/survey-append-error"));
// Need to manually assign a div for the ref to trigger the append error path
act(() => {
result.current.qrCodeRef.current = document.createElement("div");
});
// Rerender to trigger useEffect after ref is set
renderHook(() => useSurveyQRCode("https://example.com/survey-append-error"), { initialProps: result });
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_generate_qr_code");
});
test("should call toast.error if download fails", () => {
const surveyUrl = "https://example.com/survey-download-error";
const { result } = renderHook(() => useSurveyQRCode(surveyUrl));
vi.mocked(QRCodeStyling).mockImplementationOnce(
() =>
({
update: vi.fn(),
append: vi.fn(),
download: vi.fn(() => {
throw new Error("Download failed");
}),
}) as any
);
act(() => {
result.current.downloadQRCode();
});
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_generate_qr_code");
});
test("should not create new QRCodeStyling instance if one already exists for display", () => {
const surveyUrl = "https://example.com/survey1";
const { rerender } = renderHook(() => useSurveyQRCode(surveyUrl));
expect(QRCodeStyling).toHaveBeenCalledTimes(1);
rerender(); // Rerender with same props
expect(QRCodeStyling).toHaveBeenCalledTimes(1); // Should not create a new instance
});
});

View File

@@ -0,0 +1,516 @@
import { cache } from "@/lib/cache";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TLanguage } from "@formbricks/types/project";
import { TResponseFilterCriteria } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import {
getQuestionSummary,
getResponsesForSummary,
getSurveySummary,
getSurveySummaryDropOff,
getSurveySummaryMeta,
} from "./surveySummary";
// Ensure this path is correct
import { convertFloatTo2Decimal } from "./utils";
// Mock dependencies
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn().mockImplementation((fn) => fn),
};
});
vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn((value, lang) => value[lang] || value.default || ""),
}));
vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/response/utils", () => ({
buildWhereClause: vi.fn(() => ({})),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
findMany: vi.fn(),
},
},
}));
vi.mock("./utils", () => ({
convertFloatTo2Decimal: vi.fn((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
),
}));
const mockSurveyId = "survey_123";
const mockBaseSurvey: TSurvey = {
id: mockSurveyId,
name: "Test Survey",
questions: [],
welcomeCard: { enabled: false, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
],
variables: [],
autoClose: null,
triggers: [],
status: "inProgress",
type: "app",
styling: {},
segment: null,
recontactDays: null,
autoComplete: null,
closeOnDate: null,
createdAt: new Date(),
updatedAt: new Date(),
displayOption: "displayOnce",
displayPercentage: null,
environmentId: "env_123",
singleUse: null,
surveyClosedMessage: null,
resultShareKey: null,
pin: null,
createdBy: "user_123",
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
} as unknown as TSurvey;
const mockResponses = [
{
id: "res1",
data: { q1: "Answer 1" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 100, _total: 100 },
finished: true,
},
{
id: "res2",
data: { q1: "Answer 2" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 150, _total: 150 },
finished: true,
},
{
id: "res3",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: {},
finished: false,
},
] as any;
describe("getSurveySummaryMeta", () => {
beforeEach(() => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
expect(meta.completedResponses).toBe(2);
expect(meta.completedPercentage).toBe(20);
expect(meta.dropOffCount).toBe(1);
expect(meta.dropOffPercentage).toBe(33.33); // (1/3)*100
expect(meta.ttcAverage).toBe(125); // (100+150)/2
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
expect(meta.dropOffPercentage).toBe(0);
expect(meta.ttcAverage).toBe(0);
});
});
describe("getSurveySummaryDropOff", () => {
const surveyWithQuestions: TSurvey = {
...mockBaseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
} as unknown as TSurveyQuestion,
] as TSurveyQuestion[],
};
beforeEach(() => {
vi.mocked(getLocalizedValue).mockImplementation((val, _) => val?.default || "");
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
vi.mocked(performActions).mockReturnValue({
jumpTarget: undefined,
requiredQuestionIds: [],
calculations: {},
});
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("calculates dropOff correctly with welcome card disabled", () => {
const responses = [
{
id: "r1",
data: { q1: "a" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 },
finished: false,
}, // Dropped at q2
{
id: "r2",
data: { q1: "b", q2: "c" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 10 },
finished: true,
}, // Completed
] as any;
const displayCount = 5; // 5 displays
const dropOff = getSurveySummaryDropOff(surveyWithQuestions, responses, displayCount);
expect(dropOff.length).toBe(2);
// Q1
expect(dropOff[0].questionId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
expect(dropOff[0].ttc).toBe(10);
// Q2
expect(dropOff[1].questionId).toBe("q2");
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(10);
});
test("handles logic jumps", () => {
const surveyWithLogic: TSurvey = {
...mockBaseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
logic: [{ conditions: [], actions: [{ type: "jumpTo", details: { value: "q4" } }] }],
} as unknown as TSurveyQuestion,
{ id: "q3", type: TSurveyQuestionTypeEnum.OpenText, headline: { default: "Q3" }, required: true },
{ id: "q4", type: TSurveyQuestionTypeEnum.OpenText, headline: { default: "Q4" }, required: true },
] as TSurveyQuestion[],
};
const responses = [
{
id: "r1",
data: { q1: "a", q2: "b" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10, q2: 10 },
finished: false,
}, // Jumps from q2 to q4, drops at q4
];
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
// Simulate logic on q2 triggering
return data.q2 === "b";
});
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
if ((actions[0] as any).type === "jumpTo") {
return { jumpTarget: (actions[0] as any).details.value, requiredQuestionIds: [], calculations: {} };
}
return { jumpTarget: undefined, requiredQuestionIds: [], calculations: {} };
});
const dropOff = getSurveySummaryDropOff(surveyWithLogic, responses, 1);
expect(dropOff[0].impressions).toBe(1); // q1
expect(dropOff[1].impressions).toBe(1); // q2
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
});
});
describe("getQuestionSummary", () => {
const survey: TSurvey = {
...mockBaseSurvey,
questions: [
{
id: "q_open",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text" },
} as unknown as TSurveyQuestion,
{
id: "q_multi_single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Multi Single" },
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
} as unknown as TSurveyQuestion,
] as TSurveyQuestion[],
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
};
const responses = [
{
id: "r1",
data: { q_open: "Open answer", q_multi_single: "Choice 1", hidden1: "Hidden val" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: {},
finished: true,
},
];
const mockDropOff: TSurveySummary["dropOff"] = []; // Simplified for this test
beforeEach(() => {
vi.mocked(getLocalizedValue).mockImplementation((val, _) => val?.default || "");
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("summarizes OpenText questions", async () => {
const summary = await getQuestionSummary(survey, responses, mockDropOff);
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
expect(openTextSummary?.responseCount).toBe(1);
// @ts-expect-error
expect(openTextSummary?.samples[0].value).toBe("Open answer");
});
test("summarizes MultipleChoiceSingle questions", async () => {
const summary = await getQuestionSummary(survey, responses, mockDropOff);
const multiSingleSummary = summary.find((s: any) => s.question?.id === "q_multi_single");
expect(multiSingleSummary?.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceSingle);
expect(multiSingleSummary?.responseCount).toBe(1);
// @ts-expect-error
expect(multiSingleSummary?.choices[0].value).toBe("Choice 1");
// @ts-expect-error
expect(multiSingleSummary?.choices[0].count).toBe(1);
// @ts-expect-error
expect(multiSingleSummary?.choices[0].percentage).toBe(100);
});
test("summarizes HiddenFields", async () => {
const summary = await getQuestionSummary(survey, responses, mockDropOff);
const hiddenFieldSummary = summary.find((s) => s.type === "hiddenField" && s.id === "hidden1");
expect(hiddenFieldSummary).toBeDefined();
expect(hiddenFieldSummary?.responseCount).toBe(1);
// @ts-expect-error
expect(hiddenFieldSummary?.samples[0].value).toBe("Hidden val");
});
// Add more tests for other question types (NPS, CTA, Rating, etc.)
});
describe("getSurveySummary", () => {
beforeEach(() => {
vi.resetAllMocks();
// Default mocks for services
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponses.length);
// For getResponsesForSummary mock, we need to ensure it's correctly used by getSurveySummary
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary.
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
// Mock internal function calls if they are complex, otherwise let them run with mocked data
// For simplicity, we can assume getSurveySummaryDropOff and getQuestionSummary are tested independently
// and will work correctly if their inputs (survey, responses, displayCount) are correct.
// Or, provide simplified mocks for them if needed.
vi.mocked(getLocalizedValue).mockImplementation((val, _) => val?.default || "");
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("returns survey summary successfully", async () => {
const summary = await getSurveySummary(mockSurveyId);
expect(summary.meta.totalResponses).toBe(mockResponses.length);
expect(summary.meta.displayCount).toBe(10);
expect(summary.dropOff).toBeDefined();
expect(summary.summary).toBeDefined();
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(prisma.response.findMany).toHaveBeenCalled(); // Check if getResponsesForSummary was effectively called
expect(getDisplayCountBySurveyId).toHaveBeenCalled();
});
test("throws ResourceNotFoundError if survey not found", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
await expect(getSurveySummary(mockSurveyId)).rejects.toThrow(ResourceNotFoundError);
});
test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true };
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(2); // Assume 2 finished responses
const finishedResponses = mockResponses
.filter((r) => r.finished)
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
await getSurveySummary(mockSurveyId, filterCriteria);
expect(getResponseCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, filterCriteria);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ surveyId: mockSurveyId }), // buildWhereClause is mocked
})
);
expect(getDisplayCountBySurveyId).toHaveBeenCalledWith(
mockSurveyId,
expect.objectContaining({ responseIds: expect.any(Array) })
);
});
});
describe("getResponsesForSummary", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("fetches and transforms responses", async () => {
const limit = 2;
const offset = 0;
const result = await getResponsesForSummary(mockSurveyId, limit, offset);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: limit,
skip: offset,
where: { surveyId: mockSurveyId }, // buildWhereClause is mocked to return {}
})
);
expect(result.length).toBe(mockResponses.length); // Mock returns all, actual would be limited by prisma
expect(result[0].id).toBe(mockResponses[0].id);
expect(result[0].contact).toBeNull(); // As per transformation logic
});
test("returns empty array if survey not found", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const result = await getResponsesForSummary(mockSurveyId, 10, 0);
expect(result).toEqual([]);
});
test("throws DatabaseError on prisma failure", async () => {
vi.mocked(prisma.response.findMany).mockRejectedValue(new Error("DB error"));
await expect(getResponsesForSummary(mockSurveyId, 10, 0)).rejects.toThrow("DB error");
});
});
// Add afterEach to clear mocks if not using vi.resetAllMocks() in beforeEach
afterEach(() => {
vi.clearAllMocks();
});

View File

@@ -0,0 +1,204 @@
import { describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils";
describe("Utils Tests", () => {
describe("convertFloatToNDecimal", () => {
test("should round to N decimal places", () => {
expect(convertFloatToNDecimal(3.14159, 2)).toBe(3.14);
expect(convertFloatToNDecimal(3.14159, 3)).toBe(3.142);
expect(convertFloatToNDecimal(3.1, 2)).toBe(3.1);
expect(convertFloatToNDecimal(3, 2)).toBe(3);
expect(convertFloatToNDecimal(0.129, 2)).toBe(0.13);
});
test("should default to 2 decimal places if N is not provided", () => {
expect(convertFloatToNDecimal(3.14159)).toBe(3.14);
});
});
describe("convertFloatTo2Decimal", () => {
test("should round to 2 decimal places", () => {
expect(convertFloatTo2Decimal(3.14159)).toBe(3.14);
expect(convertFloatTo2Decimal(3.1)).toBe(3.1);
expect(convertFloatTo2Decimal(3)).toBe(3);
expect(convertFloatTo2Decimal(0.129)).toBe(0.13);
});
});
describe("constructToastMessage", () => {
const mockT = vi.fn((key, params) => `${key} ${JSON.stringify(params)}`) as any;
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
environmentId: "env1",
status: "draft",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Q2" },
required: false,
choices: [{ id: "c1", label: { default: "Choice 1" } }],
},
{
id: "q3",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Q3" },
required: false,
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "col1", label: { default: "Col 1" } }],
},
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
resultShareKey: null,
displayOption: "displayOnce",
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
} as unknown as TSurvey;
test("should construct message for matrix question type", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.Matrix,
"is",
mockSurvey,
"q3",
mockT,
"MatrixValue"
);
expect(mockT).toHaveBeenCalledWith(
"environments.surveys.summary.added_filter_for_responses_where_answer_to_question",
{
questionIdx: 3,
filterComboBoxValue: "MatrixValue",
filterValue: "is",
}
);
expect(message).toBe(
'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":3,"filterComboBoxValue":"MatrixValue","filterValue":"is"}'
);
});
test("should construct message for matrix question type with array filterComboBoxValue", () => {
const message = constructToastMessage(TSurveyQuestionTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
"MatrixValue1",
"MatrixValue2",
]);
expect(mockT).toHaveBeenCalledWith(
"environments.surveys.summary.added_filter_for_responses_where_answer_to_question",
{
questionIdx: 3,
filterComboBoxValue: "MatrixValue1,MatrixValue2",
filterValue: "is",
}
);
expect(message).toBe(
'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":3,"filterComboBoxValue":"MatrixValue1,MatrixValue2","filterValue":"is"}'
);
});
test("should construct message when filterComboBoxValue is undefined (skipped)", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.OpenText,
"is skipped",
mockSurvey,
"q1",
mockT,
undefined
);
expect(mockT).toHaveBeenCalledWith(
"environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped",
{
questionIdx: 1,
}
);
expect(message).toBe(
'environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped {"questionIdx":1}'
);
});
test("should construct message for non-matrix question with string filterComboBoxValue", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
"is",
mockSurvey,
"q2",
mockT,
"Choice1"
);
expect(mockT).toHaveBeenCalledWith(
"environments.surveys.summary.added_filter_for_responses_where_answer_to_question",
{
questionIdx: 2,
filterComboBoxValue: "Choice1",
filterValue: "is",
}
);
expect(message).toBe(
'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":2,"filterComboBoxValue":"Choice1","filterValue":"is"}'
);
});
test("should construct message for non-matrix question with array filterComboBoxValue", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
"includes all of",
mockSurvey,
"q2", // Assuming q2 can be multi for this test case logic
mockT,
["Choice1", "Choice2"]
);
expect(mockT).toHaveBeenCalledWith(
"environments.surveys.summary.added_filter_for_responses_where_answer_to_question",
{
questionIdx: 2,
filterComboBoxValue: "Choice1,Choice2",
filterValue: "includes all of",
}
);
expect(message).toBe(
'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":2,"filterComboBoxValue":"Choice1,Choice2","filterValue":"includes all of"}'
);
});
test("should handle questionId not found in survey", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.OpenText,
"is",
mockSurvey,
"qNonExistent",
mockT,
"SomeValue"
);
// findIndex returns -1, so questionIdx becomes -1 + 1 = 0
expect(mockT).toHaveBeenCalledWith(
"environments.surveys.summary.added_filter_for_responses_where_answer_to_question",
{
questionIdx: 0,
filterComboBoxValue: "SomeValue",
filterValue: "is",
}
);
expect(message).toBe(
'environments.surveys.summary.added_filter_for_responses_where_answer_to_question {"questionIdx":0,"filterComboBoxValue":"SomeValue","filterValue":"is"}'
);
});
});
});

View File

@@ -0,0 +1,39 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }) => <h1>{pageTitle}</h1>,
}));
vi.mock("@/modules/ui/components/skeleton-loader", () => ({
SkeletonLoader: ({ type }) => <div data-testid="skeleton-loader">{`Skeleton type: ${type}`}</div>,
}));
describe("Loading Component", () => {
afterEach(() => {
cleanup();
});
test("should render the loading state correctly", () => {
render(<Loading />);
expect(screen.getByText("common.summary")).toBeInTheDocument();
expect(screen.getByTestId("skeleton-loader")).toHaveTextContent("Skeleton type: summary");
const pulseDivs = screen.getAllByRole("generic", { hidden: true }); // Using generic role as divs don't have implicit roles
// Filter divs that are part of the pulse animation
const animatedDivs = pulseDivs.filter(
(div) =>
div.classList.contains("h-9") &&
div.classList.contains("w-36") &&
div.classList.contains("rounded-full") &&
div.classList.contains("bg-slate-200")
);
expect(animatedDivs.length).toBe(4);
});
});

View File

@@ -0,0 +1,265 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation",
() => ({
SurveyAnalysisNavigation: vi.fn(() => <div data-testid="survey-analysis-navigation"></div>),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage",
() => ({
SummaryPage: vi.fn(() => <div data-testid="summary-page"></div>),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA",
() => ({
SurveyAnalysisCTA: vi.fn(() => <div data-testid="survey-analysis-cta"></div>),
})
);
vi.mock("@/lib/getSurveyUrl", () => ({
getSurveyDomain: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div data-testid="page-content-wrapper">{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ children }) => <div data-testid="page-header">{children}</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div data-testid="settings-id"></div>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
notFound: vi.fn(),
}));
const mockEnvironmentId = "test-environment-id";
const mockSurveyId = "test-survey-id";
const mockUserId = "test-user-id";
const mockEnvironment = {
id: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockSurvey = {
id: mockSurveyId,
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: mockEnvironmentId,
status: "draft",
questions: [],
displayOption: "displayOnce",
autoClose: null,
triggers: [],
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
languages: [],
resultShareKey: null,
runOnDate: null,
singleUse: null,
surveyClosedMessage: null,
segment: null,
styling: null,
variables: [],
hiddenFields: { enabled: true, fieldIds: [] },
} as unknown as TSurvey;
const mockUser = {
id: mockUserId,
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
onboardingCompleted: true,
role: "project_manager",
locale: "en-US",
objective: "other",
} as unknown as TUser;
const mockSession = {
user: {
id: mockUserId,
name: mockUser.name,
email: mockUser.email,
image: mockUser.imageUrl,
role: mockUser.role,
plan: "free",
status: "active",
objective: "other",
},
expires: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
} as any;
describe("SurveyPage", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: mockSession,
environment: mockEnvironment,
isReadOnly: false,
} as unknown as TEnvironmentAuth);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
vi.mocked(notFound).mockClear();
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("renders correctly with valid data", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
render(await SurveyPage({ params }));
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
expect(screen.getByTestId("summary-page")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
expect(vi.mocked(getResponseCountBySurveyId)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
expect.objectContaining({
environmentId: mockEnvironmentId,
survey: mockSurvey,
activeId: "summary",
initialTotalResponseCount: 10,
})
);
expect(vi.mocked(SummaryPage).mock.calls[0][0]).toEqual(
expect.objectContaining({
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
webAppUrl: WEBAPP_URL,
user: mockUser,
totalResponseCount: 10,
documentsPerPage: DOCUMENTS_PER_PAGE,
isReadOnly: false,
locale: mockUser.locale ?? DEFAULT_LOCALE,
})
);
});
test("calls notFound if surveyId is not present in params", async () => {
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: undefined }) as any;
render(await SurveyPage({ params }));
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
test("throws error if survey is not found", async () => {
vi.mocked(getSurvey).mockResolvedValue(null);
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
try {
// We need to await the component itself because it's an async component
const SurveyPageComponent = await SurveyPage({ params });
render(SurveyPageComponent);
} catch (e: any) {
expect(e.message).toBe("common.survey_not_found");
}
// Ensure notFound was not called for this specific error
expect(vi.mocked(notFound)).not.toHaveBeenCalled();
});
test("throws error if user is not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
const params = Promise.resolve({ environmentId: mockEnvironmentId, surveyId: mockSurveyId });
try {
const SurveyPageComponent = await SurveyPage({ params });
render(SurveyPageComponent);
} catch (e: any) {
expect(e.message).toBe("common.user_not_found");
}
expect(vi.mocked(notFound)).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,202 @@
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { format } from "date-fns";
import { useParams } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { CustomFilter } from "./CustomFilter";
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getResponsesDownloadUrlAction: vi.fn(),
}));
vi.mock("@/app/lib/surveys/surveys", async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
getFormattedFilters: vi.fn(),
getTodayDate: vi.fn(),
};
});
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@/lib/utils/hooks/useClickOutside", () => ({
useClickOutside: vi.fn(),
}));
vi.mock("@/modules/ui/components/calendar", () => ({
Calendar: vi.fn(
({
onDayClick,
onDayMouseEnter,
onDayMouseLeave,
selected,
defaultMonth,
mode,
numberOfMonths,
classNames,
autoFocus,
}) => (
<div data-testid="calendar-mock">
<span>Calendar Mock</span>
<button data-testid="calendar-day-button" onClick={() => onDayClick?.(new Date("2024-01-15"))}>
<span>Click Day</span>
</button>
<div
data-testid="calendar-hover-day" // NOSONAR
onMouseEnter={() => onDayMouseEnter?.(new Date("2024-01-10"))}>
<span>Hover Day</span>
</div>
<div
data-testid="calendar-leave-day" // NOSONAR
onMouseLeave={() => onDayMouseLeave?.()}>
<span>Leave Day</span>
</div>
<div>
Selected: {selected?.from?.toISOString()} - {selected?.to?.toISOString()}
</div>
<div>Default Month: {defaultMonth?.toISOString()}</div>
<div>Mode: {mode}</div>
<div>Number of Months: {numberOfMonths}</div>
<div>ClassNames: {JSON.stringify(classNames)}</div>
<div>AutoFocus: {String(autoFocus)}</div>
</div>
)
),
}));
vi.mock("next/navigation", () => ({
useParams: vi.fn(),
}));
vi.mock("./ResponseFilter", () => ({
ResponseFilter: vi.fn(() => <div data-testid="response-filter-mock">ResponseFilter Mock</div>),
}));
const mockSurvey = {
id: "survey-1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
type: "app",
environmentId: "env-1",
status: "inProgress",
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
resultShareKey: null,
displayPercentage: null,
languages: [],
triggers: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
} as unknown as TSurvey;
const mockDateToday = new Date("2023-11-20T00:00:00.000Z");
const initialMockUseResponseFilterState = () => ({
selectedFilter: {},
dateRange: { from: undefined, to: mockDateToday },
setDateRange: vi.fn(),
resetState: vi.fn(),
});
let mockUseResponseFilterState = initialMockUseResponseFilterState();
describe("CustomFilter", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
mockUseResponseFilterState = initialMockUseResponseFilterState(); // Reset state for each test
vi.mocked(useResponseFilter).mockImplementation(() => mockUseResponseFilterState as any);
vi.mocked(useParams).mockReturnValue({ environmentId: "test-env", surveyId: "test-survey" });
vi.mocked(getFormattedFilters).mockReturnValue({});
vi.mocked(getTodayDate).mockReturnValue(mockDateToday);
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValue({ data: "mock-download-url" });
vi.mocked(getFormattedErrorMessage).mockReturnValue("Mock error message");
});
test("renders correctly with initial props", () => {
render(<CustomFilter survey={mockSurvey} />);
expect(screen.getByTestId("response-filter-mock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.all_time")).toBeInTheDocument();
expect(screen.getByText("common.download")).toBeInTheDocument();
});
test("opens custom date picker when 'Custom range' is clicked", async () => {
const user = userEvent.setup();
render(<CustomFilter survey={mockSurvey} />);
const dropdownTrigger = screen.getByText("environments.surveys.summary.all_time").closest("button")!;
// Similar to above, assuming direct clickability.
await user.click(dropdownTrigger);
const customRangeOption = screen.getByText("environments.surveys.summary.custom_range");
await user.click(customRangeOption);
expect(screen.getByTestId("calendar-mock")).toBeVisible();
expect(screen.getByText(`Select first date - ${format(mockDateToday, "dd LLL")}`)).toBeInTheDocument();
});
test("does not render download button on sharing page", () => {
vi.mocked(useParams).mockReturnValue({
environmentId: "test-env",
surveyId: "test-survey",
sharingKey: "test-share-key",
});
render(<CustomFilter survey={mockSurvey} />);
expect(screen.queryByText("common.download")).not.toBeInTheDocument();
});
test("useEffect logic for resetState and firstMountRef (as per current component code)", () => {
// This test verifies the current behavior of the useEffects related to firstMountRef.
// Based on the component's code, resetState() is not expected to be called by these effects,
// and firstMountRef.current is not changed by the first useEffect.
const { rerender } = render(<CustomFilter survey={mockSurvey} />);
expect(mockUseResponseFilterState.resetState).not.toHaveBeenCalled();
const newSurvey = { ...mockSurvey, id: "survey-2" };
rerender(<CustomFilter survey={newSurvey} />);
expect(mockUseResponseFilterState.resetState).not.toHaveBeenCalled();
});
test("closes date picker when clicking outside", async () => {
const user = userEvent.setup();
let clickOutsideCallback: Function = () => {};
vi.mocked(useClickOutside).mockImplementation((_, callback) => {
clickOutsideCallback = callback;
});
render(<CustomFilter survey={mockSurvey} />);
const dropdownTrigger = screen.getByText("environments.surveys.summary.all_time").closest("button")!; // Ensure targeting button
await user.click(dropdownTrigger);
const customRangeOption = screen.getByText("environments.surveys.summary.custom_range");
await user.click(customRangeOption);
expect(screen.getByTestId("calendar-mock")).toBeVisible();
clickOutsideCallback(); // Simulate click outside
await waitFor(() => {
expect(screen.queryByTestId("calendar-mock")).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,257 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ResultsShareButton } from "./ResultsShareButton";
// Mock actions
const mockDeleteResultShareUrlAction = vi.fn();
const mockGenerateResultShareUrlAction = vi.fn();
const mockGetResultShareUrlAction = vi.fn();
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
deleteResultShareUrlAction: (...args) => mockDeleteResultShareUrlAction(...args),
generateResultShareUrlAction: (...args) => mockGenerateResultShareUrlAction(...args),
getResultShareUrlAction: (...args) => mockGetResultShareUrlAction(...args),
}));
// Mock helper
const mockGetFormattedErrorMessage = vi.fn((error) => error?.message || "An error occurred");
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: (error) => mockGetFormattedErrorMessage(error),
}));
// Mock UI components
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children }) => <div data-testid="dropdown-menu">{children}</div>,
DropdownMenuContent: ({ children, align }) => (
<div data-testid="dropdown-menu-content" data-align={align}>
{children}
</div>
),
DropdownMenuItem: ({ children, onClick, icon }) => (
<button data-testid="dropdown-menu-item" onClick={onClick}>
{icon}
{children}
</button>
),
DropdownMenuTrigger: ({ children }) => <div data-testid="dropdown-menu-trigger">{children}</div>,
}));
// Mock Tolgee
const mockT = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: mockT }),
}));
// Mock icons
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon" />,
DownloadIcon: () => <div data-testid="download-icon" />,
GlobeIcon: () => <div data-testid="globe-icon" />,
LinkIcon: () => <div data-testid="link-icon" />,
}));
// Mock toast
const mockToastSuccess = vi.fn();
const mockToastError = vi.fn();
vi.mock("react-hot-toast", () => ({
default: {
success: (...args) => mockToastSuccess(...args),
error: (...args) => mockToastError(...args),
},
}));
// Mock ShareSurveyResults component
const mockShareSurveyResults = vi.fn();
vi.mock("../(analysis)/summary/components/ShareSurveyResults", () => ({
ShareSurveyResults: (props) => {
mockShareSurveyResults(props);
return props.open ? (
<div data-testid="share-survey-results-modal">
<span>ShareSurveyResults Modal</span>
<button onClick={() => props.setOpen(false)}>Close Modal</button>
<button data-testid="handle-publish-button" onClick={props.handlePublish}>
Publish
</button>
<button data-testid="handle-unpublish-button" onClick={props.handleUnpublish}>
Unpublish
</button>
</div>
) : null;
},
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "inProgress",
questions: [],
hiddenFields: { enabled: false },
displayOption: "displayOnce",
recontactDays: 0,
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
resultShareKey: null,
languages: [],
triggers: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
styling: null,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
variables: [],
closeOnDate: null,
} as unknown as TSurvey;
const webAppUrl = "https://app.formbricks.com";
const originalLocation = window.location;
describe("ResultsShareButton", () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock window.location.href
Object.defineProperty(window, "location", {
writable: true,
value: { ...originalLocation, href: "https://app.formbricks.com/surveys/survey1" },
});
// Mock navigator.clipboard
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
writable: true,
});
});
afterEach(() => {
cleanup();
Object.defineProperty(window, "location", {
writable: true,
value: originalLocation,
});
});
test("renders initial state and fetches sharing key (no existing key)", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
expect(mockGetResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
await waitFor(() => {
expect(screen.queryByTestId("share-survey-results-modal")).not.toBeInTheDocument();
});
});
test("handles copy private link to clipboard", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("common.copy_link")
);
expect(copyLinkButton).toBeInTheDocument();
await userEvent.click(copyLinkButton!);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.href);
expect(mockToastSuccess).toHaveBeenCalledWith("common.copied_to_clipboard");
});
test("handles copy public link to clipboard", async () => {
const shareKey = "publicShareKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("environments.surveys.summary.copy_link_to_public_results")
);
expect(copyPublicLinkButton).toBeInTheDocument();
await userEvent.click(copyPublicLinkButton!);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`${webAppUrl}/share/${shareKey}`);
expect(mockToastSuccess).toHaveBeenCalledWith(
"environments.surveys.summary.link_to_public_results_copied"
);
});
test("handles publish to web successfully", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("environments.surveys.summary.publish_to_web")
);
await userEvent.click(publishButton!);
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("handle-publish-button"));
expect(mockGenerateResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
await waitFor(() => {
expect(mockShareSurveyResults).toHaveBeenCalledWith(
expect.objectContaining({
surveyUrl: `${webAppUrl}/share/newShareKey`,
showPublishModal: true,
})
);
});
});
test("handles unpublish from web successfully", async () => {
const shareKey = "toUnpublishKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("environments.surveys.summary.unpublish_from_web")
);
await userEvent.click(unpublishButton!);
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("handle-unpublish-button"));
expect(mockDeleteResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
expect(mockToastSuccess).toHaveBeenCalledWith("environments.surveys.results_unpublished_successfully");
await waitFor(() => {
expect(mockShareSurveyResults).toHaveBeenCalledWith(
expect.objectContaining({
showPublishModal: false,
})
);
});
});
test("opens and closes ShareSurveyResults modal", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
item.textContent?.includes("environments.surveys.summary.publish_to_web")
);
await userEvent.click(publishButton!);
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
expect(mockShareSurveyResults).toHaveBeenCalledWith(
expect.objectContaining({
open: true,
surveyUrl: "", // Initially empty as no key fetched yet for this flow
showPublishModal: false, // Initially false
})
);
await userEvent.click(screen.getByText("Close Modal"));
expect(screen.queryByTestId("share-survey-results-modal")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,182 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SurveyStatusDropdown } from "./SurveyStatusDropdown";
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((error) => error?.message || "An error occurred"),
}));
vi.mock("@/modules/ui/components/select", () => ({
Select: vi.fn(({ value, onValueChange, disabled, children }) => (
<div data-testid="select-container" data-disabled={disabled}>
<div data-testid="select-value">{value}</div>
{children}
<button data-testid="select-trigger" onClick={() => onValueChange("paused")}>
Trigger Change
</button>
</div>
)),
SelectContent: vi.fn(({ children }) => <div data-testid="select-content">{children}</div>),
SelectItem: vi.fn(({ value, children }) => <div data-testid={`select-item-${value}`}>{children}</div>),
SelectTrigger: vi.fn(({ children }) => <div data-testid="actual-select-trigger">{children}</div>),
SelectValue: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/survey-status-indicator", () => ({
SurveyStatusIndicator: vi.fn(({ status }) => (
<div data-testid="survey-status-indicator">{`Status: ${status}`}</div>
)),
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: vi.fn(({ children }) => <div data-testid="tooltip">{children}</div>),
TooltipContent: vi.fn(({ children }) => <div data-testid="tooltip-content">{children}</div>),
TooltipProvider: vi.fn(({ children }) => <div>{children}</div>),
TooltipTrigger: vi.fn(({ children }) => <div data-testid="tooltip-trigger">{children}</div>),
}));
vi.mock("../actions", () => ({
updateSurveyAction: vi.fn(),
}));
const mockEnvironment: TEnvironment = {
id: "env_1",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "proj_1",
type: "production",
appSetupCompleted: true,
productOverwrites: null,
brandLinks: null,
recontactDays: 30,
displayBranding: true,
highlightBorderColor: null,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
};
const baseSurvey: TSurvey = {
id: "survey_1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: "env_1",
status: "draft",
questions: [],
hiddenFields: { enabled: true, fieldIds: [] },
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
delay: 0,
displayPercentage: null,
redirectUrl: null,
welcomeCard: { enabled: true } as TSurvey["welcomeCard"],
languages: [],
styling: null,
variables: [],
triggers: [],
numDisplays: 0,
responseRate: 0,
responses: [],
summary: { completedResponses: 0, displays: 0, totalResponses: 0, startsPercentage: 0 },
isResponseEncryptionEnabled: false,
isSingleUse: false,
segment: null,
surveyClosedMessage: null,
resultShareKey: null,
singleUse: null,
verifyEmail: null,
pin: null,
closeOnDate: null,
productOverwrites: null,
analytics: {
numCTA: 0,
numDisplays: 0,
numResponses: 0,
numStarts: 0,
responseRate: 0,
startRate: 0,
totalCompletedResponses: 0,
totalDisplays: 0,
totalResponses: 0,
},
createdBy: null,
autoComplete: null,
runOnDate: null,
endings: [],
};
describe("SurveyStatusDropdown", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders draft status correctly", () => {
render(
<SurveyStatusDropdown environment={mockEnvironment} survey={{ ...baseSurvey, status: "draft" }} />
);
expect(screen.getByText("common.draft")).toBeInTheDocument();
expect(screen.queryByTestId("select-container")).toBeNull();
});
test("disables select when status is scheduled", () => {
render(
<SurveyStatusDropdown environment={mockEnvironment} survey={{ ...baseSurvey, status: "scheduled" }} />
);
expect(screen.getByTestId("select-container")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(
"environments.surveys.survey_status_tooltip"
);
});
test("disables select when closeOnDate is in the past", () => {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 1);
render(
<SurveyStatusDropdown
environment={mockEnvironment}
survey={{ ...baseSurvey, status: "inProgress", closeOnDate: pastDate }}
/>
);
expect(screen.getByTestId("select-container")).toHaveAttribute("data-disabled", "true");
});
test("renders SurveyStatusIndicator for link survey", () => {
render(
<SurveyStatusDropdown
environment={mockEnvironment}
survey={{ ...baseSurvey, status: "inProgress", type: "link" }}
/>
);
const actualSelectTrigger = screen.getByTestId("actual-select-trigger");
expect(within(actualSelectTrigger).getByTestId("survey-status-indicator")).toBeInTheDocument();
});
test("renders SurveyStatusIndicator when appSetupCompleted is true", () => {
render(
<SurveyStatusDropdown
environment={{ ...mockEnvironment, appSetupCompleted: true }}
survey={{ ...baseSurvey, status: "inProgress", type: "app" }}
/>
);
const actualSelectTrigger = screen.getByTestId("actual-select-trigger");
expect(within(actualSelectTrigger).getByTestId("survey-status-indicator")).toBeInTheDocument();
});
test("does not render SurveyStatusIndicator when appSetupCompleted is false for non-link survey", () => {
render(
<SurveyStatusDropdown
environment={{ ...mockEnvironment, appSetupCompleted: false }}
survey={{ ...baseSurvey, status: "inProgress", type: "app" }}
/>
);
const actualSelectTrigger = screen.getByTestId("actual-select-trigger");
expect(within(actualSelectTrigger).queryByTestId("survey-status-indicator")).toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("SurveyPage", () => {
test("should redirect to the survey summary page", async () => {
const params = {
environmentId: "testEnvId",
surveyId: "testSurveyId",
};
const props = { params };
await Page(props);
expect(vi.mocked(redirect)).toHaveBeenCalledWith(
`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`
);
});
});

View File

@@ -0,0 +1,15 @@
import { SurveyListLoading as OriginalSurveyListLoading } from "@/modules/survey/list/loading";
import { describe, expect, test, vi } from "vitest";
import SurveyListLoading from "./loading";
// Mock the original component to ensure we are testing the re-export
vi.mock("@/modules/survey/list/loading", () => ({
SurveyListLoading: () => <div data-testid="mock-survey-list-loading">Mock SurveyListLoading</div>,
}));
describe("SurveyListLoadingPage Re-export", () => {
test("should re-export SurveyListLoading from the correct module", () => {
// Check if the re-exported component is the same as the original (mocked) component
expect(SurveyListLoading).toBe(OriginalSurveyListLoading);
});
});

View File

@@ -0,0 +1,24 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import SurveysPage, { metadata as layoutMetadata } from "./page";
vi.mock("@/modules/survey/list/page", () => ({
SurveysPage: ({ children }) => <div data-testid="surveys-page">{children}</div>,
metadata: { title: "Mocked Surveys Page" },
}));
describe("SurveysPage", () => {
afterEach(() => {
cleanup();
});
test("renders SurveysPage", () => {
const { getByTestId } = render(<SurveysPage params={undefined as any} searchParams={undefined as any} />);
expect(getByTestId("surveys-page")).toBeInTheDocument();
expect(getByTestId("surveys-page")).toHaveTextContent("");
});
test("exports metadata from @/modules/survey/list/page", () => {
expect(layoutMetadata).toEqual({ title: "Mocked Surveys Page" });
});
});

View File

@@ -0,0 +1,19 @@
import { cleanup, render } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("Page", () => {
afterEach(() => {
cleanup();
});
test("should redirect to /", () => {
render(<Page />);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/");
});
});

View File

@@ -6,11 +6,11 @@ import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
import {
Resource,
detectResourcesSync,
detectResources,
envDetector,
hostDetector,
processDetector,
resourceFromAttributes,
} from "@opentelemetry/resources";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { logger } from "@formbricks/logger";
@@ -21,11 +21,11 @@ const exporter = new PrometheusExporter({
host: "0.0.0.0", // Listen on all network interfaces
});
const detectedResources = detectResourcesSync({
const detectedResources = detectResources({
detectors: [envDetector, processDetector, hostDetector],
});
const customResources = new Resource({});
const customResources = resourceFromAttributes({});
const resources = detectedResources.merge(customResources);

View File

@@ -82,7 +82,7 @@ export const AIRTABLE_CLIENT_ID = env.AIRTABLE_CLIENT_ID;
export const SMTP_HOST = env.SMTP_HOST;
export const SMTP_PORT = env.SMTP_PORT;
export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1";
export const SMTP_SECURE_ENABLED = env.SMTP_SECURE_ENABLED === "1" || env.SMTP_PORT === "465";
export const SMTP_USER = env.SMTP_USER;
export const SMTP_PASSWORD = env.SMTP_PASSWORD;
export const SMTP_AUTHENTICATED = env.SMTP_AUTHENTICATED !== "0";

View File

@@ -202,7 +202,7 @@ const baseSurveyProperties = {
autoComplete: 7,
runOnDate: null,
closeOnDate: currentDate,
redirectUrl: "http://github.com/formbricks/formbricks",
redirectUrl: "https://github.com/formbricks/formbricks",
recontactDays: 3,
displayLimit: 3,
welcomeCard: mockWelcomeCard,

View File

@@ -22,7 +22,7 @@ export const captureTelemetry = async (eventName: string, properties = {}) => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy",
api_key: "phc_SoIFUJ8b9ufDm0YOnoOxJf6PXyuHpO7N6RztxFdZTy", // NOSONAR // This is a public API key for telemetry and not a secret
event: eventName,
properties: {
distinct_id: getTelemetryId(),

View File

@@ -1,16 +1,32 @@
import { responses } from "@/app/lib/api/response";
import { webhookHandler } from "@/modules/ee/billing/api/lib/stripe-webhook";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
export const POST = async (request: Request) => {
const body = await request.text();
const requestHeaders = await headers();
const signature = requestHeaders.get("stripe-signature") as string;
try {
const body = await request.text();
const requestHeaders = await headers(); // Corrected: headers() is async
const signature = requestHeaders.get("stripe-signature");
const { status, message } = await webhookHandler(body, signature);
if (!signature) {
logger.warn("Stripe signature missing from request headers.");
return NextResponse.json({ message: "Stripe signature missing" }, { status: 400 });
}
if (status != 200) {
return responses.badRequestResponse(message?.toString() || "Something went wrong");
const result = await webhookHandler(body, signature);
if (result.status !== 200) {
logger.error(`Webhook handler failed with status ${result.status}: ${result.message?.toString()}`);
return NextResponse.json(
{ message: result.message?.toString() || "Webhook processing error" },
{ status: result.status }
);
}
return NextResponse.json(result.message || { received: true }, { status: 200 });
} catch (error: any) {
logger.error(error, `Unhandled error in Stripe webhook POST handler: ${error.message}`);
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
}
return responses.successResponse({ message }, true);
};

View File

@@ -73,7 +73,7 @@ export const PricingTable = ({
const manageSubscriptionResponse = await manageSubscriptionAction({
environmentId,
});
if (manageSubscriptionResponse?.data) {
if (manageSubscriptionResponse?.data && typeof manageSubscriptionResponse.data === "string") {
router.push(manageSubscriptionResponse.data);
}
};
@@ -146,7 +146,7 @@ export const PricingTable = ({
<div className="flex flex-col gap-8">
<div className="flex flex-col">
<div className="flex w-full">
<h2 className="mr-2 mb-3 inline-flex w-full text-2xl font-bold text-slate-700">
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
{t("environments.settings.billing.current_plan")}:{" "}
{capitalizeFirstLetter(organization.billing.plan)}
{cancellingOn && (
@@ -201,7 +201,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",
peopleUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
peopleUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">
{t("environments.settings.billing.monthly_identified_users")}
@@ -224,7 +224,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 flex flex-col gap-4 pb-12",
projectsUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">{t("common.projects")}</p>
{organization.billing.limits.projects && (
@@ -260,7 +260,7 @@ export const PricingTable = ({
{t("environments.settings.billing.monthly")}
</div>
<div
className={`flex-1 items-center rounded-md py-0.5 pr-2 pl-4 text-center whitespace-nowrap ${
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
}`}
onClick={() => handleMonthlyToggle("yearly")}>
@@ -272,7 +272,7 @@ export const PricingTable = ({
</div>
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
<div
className="hidden lg:absolute lg:inset-x-px lg:top-4 lg:bottom-0 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
aria-hidden="true"
/>
{getCloudPricingData(t).plans.map((plan) => (

View File

@@ -0,0 +1,113 @@
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { getProjectPermissionByUserId, getTeamRoleByTeamIdUserId } from "./roles";
vi.mock("@formbricks/database", () => ({
prisma: {
projectTeam: { findMany: vi.fn() },
teamUser: { findUnique: vi.fn() },
},
}));
vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const mockUserId = "user-1";
const mockProjectId = "project-1";
const mockTeamId = "team-1";
describe("roles lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getProjectPermissionByUserId", () => {
test("returns null if no memberships", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([]);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBeNull();
expect(validateInputs).toHaveBeenCalledWith(
[mockUserId, expect.anything()],
[mockProjectId, expect.anything()]
);
});
test("returns 'manage' if any membership has manage", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([
{ permission: "read" },
{ permission: "manage" },
{ permission: "readWrite" },
] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("manage");
});
test("returns 'readWrite' if highest is readWrite", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([
{ permission: "read" },
{ permission: "readWrite" },
] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("readWrite");
});
test("returns 'read' if only read", async () => {
vi.mocked(prisma.projectTeam.findMany).mockResolvedValueOnce([{ permission: "read" }] as any);
const result = await getProjectPermissionByUserId(mockUserId, mockProjectId);
expect(result).toBe("read");
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.projectTeam.findMany).mockRejectedValueOnce(error);
await expect(getProjectPermissionByUserId(mockUserId, mockProjectId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(error, expect.any(String));
});
test("throws UnknownError on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.projectTeam.findMany).mockRejectedValueOnce(error);
await expect(getProjectPermissionByUserId(mockUserId, mockProjectId)).rejects.toThrow(UnknownError);
});
});
describe("getTeamRoleByTeamIdUserId", () => {
test("returns null if no teamUser", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce(null);
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBeNull();
expect(validateInputs).toHaveBeenCalledWith(
[mockTeamId, expect.anything()],
[mockUserId, expect.anything()]
);
});
test("returns role if teamUser exists", async () => {
vi.mocked(prisma.teamUser.findUnique).mockResolvedValueOnce({ role: "member" });
const result = await getTeamRoleByTeamIdUserId(mockTeamId, mockUserId);
expect(result).toBe("member");
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.teamUser.findUnique).mockRejectedValueOnce(error);
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(DatabaseError);
});
test("throws error on generic error", async () => {
const error = new Error("fail");
vi.mocked(prisma.teamUser.findUnique).mockRejectedValueOnce(error);
await expect(getTeamRoleByTeamIdUserId(mockTeamId, mockUserId)).rejects.toThrow(error);
});
});
});

View File

@@ -0,0 +1,41 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessTable } from "./access-table";
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (k: string) => k }),
}));
describe("AccessTable", () => {
afterEach(() => {
cleanup();
});
test("renders no teams found row when teams is empty", () => {
render(<AccessTable teams={[]} />);
expect(screen.getByText("environments.project.teams.no_teams_found")).toBeInTheDocument();
});
test("renders team rows with correct data and permission mapping", () => {
const teams: TProjectTeam[] = [
{ id: "1", name: "Team A", memberCount: 1, permission: "readWrite" },
{ id: "2", name: "Team B", memberCount: 2, permission: "read" },
];
render(<AccessTable teams={teams} />);
expect(screen.getByText("Team A")).toBeInTheDocument();
expect(screen.getByText("Team B")).toBeInTheDocument();
expect(screen.getByText("1 common.member")).toBeInTheDocument();
expect(screen.getByText("2 common.members")).toBeInTheDocument();
expect(screen.getByText(TeamPermissionMapping["readWrite"])).toBeInTheDocument();
expect(screen.getByText(TeamPermissionMapping["read"])).toBeInTheDocument();
});
test("renders table headers with tolgee keys", () => {
render(<AccessTable teams={[]} />);
expect(screen.getByText("environments.project.teams.team_name")).toBeInTheDocument();
expect(screen.getByText("common.size")).toBeInTheDocument();
expect(screen.getByText("environments.project.teams.permission")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessView } from "./access-view";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ title, description, children }: any) => (
<div data-testid="SettingsCard">
<div>{title}</div>
<div>{description}</div>
{children}
</div>
),
}));
vi.mock("@/modules/ee/teams/project-teams/components/manage-team", () => ({
ManageTeam: ({ environmentId, isOwnerOrManager }: any) => (
<button data-testid="ManageTeam">
ManageTeam {environmentId} {isOwnerOrManager ? "owner" : "not-owner"}
</button>
),
}));
vi.mock("@/modules/ee/teams/project-teams/components/access-table", () => ({
AccessTable: ({ teams }: any) => (
<div data-testid="AccessTable">
{teams.length === 0 ? "No teams" : `Teams: ${teams.map((t: any) => t.name).join(",")}`}
</div>
),
}));
describe("AccessView", () => {
afterEach(() => {
cleanup();
});
const baseProps = {
environmentId: "env-1",
isOwnerOrManager: true,
teams: [
{ id: "1", name: "Team A", memberCount: 2, permission: "readWrite" } as TProjectTeam,
{ id: "2", name: "Team B", memberCount: 1, permission: "read" } as TProjectTeam,
],
};
test("renders SettingsCard with tolgee strings and children", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("SettingsCard")).toBeInTheDocument();
expect(screen.getByText("common.team_access")).toBeInTheDocument();
expect(screen.getByText("environments.project.teams.team_settings_description")).toBeInTheDocument();
});
test("renders ManageTeam with correct props", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("ManageTeam")).toHaveTextContent("ManageTeam env-1 owner");
});
test("renders AccessTable with teams", () => {
render(<AccessView {...baseProps} />);
expect(screen.getByTestId("AccessTable")).toHaveTextContent("Teams: Team A,Team B");
});
test("renders AccessTable with no teams", () => {
render(<AccessView {...baseProps} teams={[]} />);
expect(screen.getByTestId("AccessTable")).toHaveTextContent("No teams");
});
test("renders ManageTeam as not-owner when isOwnerOrManager is false", () => {
render(<AccessView {...baseProps} isOwnerOrManager={false} />);
expect(screen.getByTestId("ManageTeam")).toHaveTextContent("not-owner");
});
});

View File

@@ -0,0 +1,46 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ManageTeam } from "./manage-team";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ tooltipContent, children }: any) => (
<div data-testid="TooltipRenderer">
<span>{tooltipContent}</span>
{children}
</div>
),
}));
describe("ManageTeam", () => {
afterEach(() => {
cleanup();
});
test("renders enabled button and navigates when isOwnerOrManager is true", async () => {
render(<ManageTeam environmentId="env-123" isOwnerOrManager={true} />);
const button = screen.getByRole("button");
expect(button).toBeEnabled();
expect(screen.getByText("environments.project.teams.manage_teams")).toBeInTheDocument();
await userEvent.click(button);
});
test("renders disabled button with tooltip when isOwnerOrManager is false", () => {
render(<ManageTeam environmentId="env-123" isOwnerOrManager={false} />);
const button = screen.getByRole("button");
expect(button).toBeDisabled();
expect(screen.getByText("environments.project.teams.manage_teams")).toBeInTheDocument();
expect(screen.getByTestId("TooltipRenderer")).toBeInTheDocument();
expect(
screen.getByText("environments.project.teams.only_organization_owners_and_managers_can_manage_teams")
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getTeamsByProjectId } from "./team";
vi.mock("@formbricks/database", () => ({
prisma: {
project: { findUnique: vi.fn() },
team: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/cache/team", () => ({ teamCache: { tag: { byProjectId: vi.fn(), byId: vi.fn() } } }));
vi.mock("@/lib/project/cache", () => ({ projectCache: { tag: { byId: vi.fn() } } }));
const mockProject = { id: "p1" };
const mockTeams = [
{
id: "t1",
name: "Team 1",
projectTeams: [{ permission: "readWrite" }],
_count: { teamUsers: 2 },
},
{
id: "t2",
name: "Team 2",
projectTeams: [{ permission: "manage" }],
_count: { teamUsers: 3 },
},
];
describe("getTeamsByProjectId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped teams for valid project", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByProjectId("p1");
expect(result).toEqual([
{ id: "t1", name: "Team 1", permission: "readWrite", memberCount: 2 },
{ id: "t2", name: "Team 2", permission: "manage", memberCount: 3 },
]);
expect(prisma.project.findUnique).toHaveBeenCalledWith({ where: { id: "p1" } });
expect(prisma.team.findMany).toHaveBeenCalled();
});
test("throws ResourceNotFoundError if project does not exist", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(null);
await expect(getTeamsByProjectId("p1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on Prisma known error", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByProjectId("p1")).rejects.toThrow(DatabaseError);
});
test("throws unknown error on unexpected error", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValueOnce(mockProject);
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("unexpected"));
await expect(getTeamsByProjectId("p1")).rejects.toThrow("unexpected");
});
});

View File

@@ -0,0 +1,41 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamsLoading } from "./loading";
vi.mock("@/modules/projects/settings/components/project-config-navigation", () => ({
ProjectConfigNavigation: ({ activeId, loading }: any) => (
<div data-testid="ProjectConfigNavigation">{`${activeId}-${loading}`}</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: any) => <div data-testid="PageContentWrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }: any) => (
<div data-testid="PageHeader">
<span>{pageTitle}</span>
{children}
</div>
),
}));
describe("TeamsLoading", () => {
afterEach(() => {
cleanup();
});
test("renders loading skeletons and navigation", () => {
render(<TeamsLoading />);
expect(screen.getByTestId("PageContentWrapper")).toBeInTheDocument();
expect(screen.getByTestId("PageHeader")).toBeInTheDocument();
expect(screen.getByTestId("ProjectConfigNavigation")).toHaveTextContent("teams-true");
// Check for the presence of multiple skeleton loaders (at least one)
const skeletonLoaders = screen.getAllByRole("generic", { name: "" }); // Assuming skeleton divs don't have specific roles/names
// Filter for elements with animate-pulse class
const pulseElements = skeletonLoaders.filter((el) => el.classList.contains("animate-pulse"));
expect(pulseElements.length).toBeGreaterThan(0);
expect(screen.getByText("common.project_configuration")).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More