Compare commits

..

2 Commits

Author SHA1 Message Date
Dhruwang
14fef9deeb Merge branch 'main' of https://github.com/formbricks/formbricks into update-open-telemetry-deps 2025-05-07 14:11:35 +05:30
Dhruwang
4b0bccaf86 updated open telemtry package versions 2025-05-06 16:53:25 +05:30
847 changed files with 8259 additions and 77649 deletions

View File

@@ -1,6 +0,0 @@
---
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
globs:
alwaysApply: false
---
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)

View File

@@ -172,6 +172,7 @@ ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific organization and role within that organization # Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn) # Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature) # (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID= # AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO= # AUTH_SKIP_INVITE_FOR_SSO=
@@ -190,7 +191,8 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided) # The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory # You can also add more configuration to Redis using the redis.conf file in the root directory
# REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL: # REDIS_HTTP_URL:
@@ -198,6 +200,9 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Rate Limiting for management API # The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY= UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1
# INTERCOM_APP_ID= # INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY= # INTERCOM_SECRET_KEY=
@@ -211,8 +216,5 @@ UNKEY_ROOT_KEY=
# It's used automatically by Sentry during the build for authentication when uploading source maps. # It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN= # SENTRY_AUTH_TOKEN=
# Configure the minimum role for user management from UI(owner, manager, disabled) # Disable the user management from UI
# USER_MANAGEMENT_MINIMUM_ROLE="manager" # DISABLE_USER_MANAGEMENT=1
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400

View File

@@ -11,7 +11,7 @@ When generating test files inside the "/app/web" path, follow these rules:
- Follow the same test pattern used for other files in the package where the file is located - Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests - All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked" - For mocking inside "test" blocks use "vi.mocked"
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react" - Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file - Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
- When using "screen.getByText" check for the tolgee string if it is being used in the file. - When using "screen.getByText" check for the tolgee string if it is being used in the file.
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase. - The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
@@ -28,5 +28,4 @@ afterEach(() => {
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports. - The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
- For click events, import userEvent from "@testing-library/user-event" - For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components. - Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
- You don't need to mock @tolgee/react - You don't need to mock @tolgee/react
- Use "import "@testing-library/jest-dom/vitest";"

84
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
directory: "/" # Root package.json
schedule:
interval: "weekly"
versioning-strategy: increase
# Apps directory packages
- package-ecosystem: "npm"
directory: "/apps/demo"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/demo-react-native"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/storybook"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/apps/web"
schedule:
interval: "weekly"
# Packages directory
- package-ecosystem: "npm"
directory: "/packages/database"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/lib"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/types"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-eslint"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-prettier"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/config-typescript"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/js-core"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/surveys"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/packages/logger"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -10,11 +10,6 @@ jobs:
chromatic: chromatic:
name: Run Chromatic name: Run Chromatic
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

View File

@@ -24,4 +24,4 @@ jobs:
- name: 'Checkout Repository' - name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review' - name: 'Dependency Review'
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0 uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0

View File

@@ -54,7 +54,7 @@ jobs:
tags: tag:github tags: tag:github
- name: Configure AWS Credentials - name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0 uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with: with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1" aws-region: "eu-central-1"

View File

@@ -11,8 +11,6 @@ on:
required: false required: false
PLAYWRIGHT_SERVICE_URL: PLAYWRIGHT_SERVICE_URL:
required: false required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary # Add other secrets if necessary
workflow_dispatch: workflow_dispatch:
@@ -25,6 +23,7 @@ permissions:
id-token: write id-token: write
contents: read contents: read
actions: read actions: read
checks: write
jobs: jobs:
build: build:
@@ -49,17 +48,15 @@ jobs:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with: with:
egress-policy: allow egress-policy: audit
allowed-endpoints: |
ee.formbricks.com:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x - name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with: with:
node-version: 22.x node-version: 20.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
@@ -78,7 +75,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
echo "" >> .env echo "" >> .env
echo "E2E_TESTING=1" >> .env echo "E2E_TESTING=1" >> .env
shell: bash shell: bash
@@ -92,18 +89,8 @@ jobs:
# pnpm prisma migrate deploy # pnpm prisma migrate deploy
pnpm db:migrate:dev pnpm db:migrate:dev
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
if [ -z "$LICENSE_KEY" ]; then
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
exit 1
fi
echo "License key length: ${#LICENSE_KEY}"
- name: Run App - name: Run App
run: | run: |
echo "Starting app with enterprise license..."
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 & NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do for attempt in {1..10}; do

View File

@@ -20,15 +20,18 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
id-token: write
# This is used to complete the identity challenge # This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs. # with sigstore/fulcio when running outside of PRs.
id-token: write
outputs: outputs:
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }} VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}

View File

@@ -4,7 +4,7 @@ on:
workflow_call: workflow_call:
inputs: inputs:
VERSION: VERSION:
description: "The version of the Helm chart to release" description: 'The version of the Helm chart to release'
required: true required: true
type: string type: string

View File

@@ -40,7 +40,7 @@ jobs:
revert revert
ossgg ossgg
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
# When the previous steps fails, the workflow would stop. By adding this # When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message. # condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
@@ -59,7 +59,7 @@ jobs:
# Delete a previous comment when the issue has been resolved # Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
with: with:
header: pr-title-lint-error header: pr-title-lint-error
message: | message: |

View File

@@ -48,7 +48,7 @@ jobs:
run: | run: |
pnpm test:coverage pnpm test:coverage
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -1,8 +1,8 @@
name: "Terraform" name: 'Terraform'
on: on:
workflow_dispatch: workflow_dispatch:
# TODO: enable it back when migration is completed. # TODO: enable it back when migration is completed.
push: push:
branches: branches:
- main - main
@@ -14,13 +14,14 @@ on:
paths: paths:
- "infra/terraform/**" - "infra/terraform/**"
permissions:
id-token: write
contents: write
pull-requests: write
jobs: jobs:
terraform: terraform:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
@@ -40,7 +41,7 @@ jobs:
tags: tag:github tags: tag:github
- name: Configure AWS Credentials - name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0 uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with: with:
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
aws-region: "eu-central-1" aws-region: "eu-central-1"
@@ -70,7 +71,7 @@ jobs:
working-directory: infra/terraform working-directory: infra/terraform
- name: Post PR comment - name: Post PR comment
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1 uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure') if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
with: with:
token: ${{ github.token }} token: ${{ github.token }}
@@ -82,3 +83,4 @@ jobs:
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply .planfile run: terraform apply .planfile
working-directory: "infra/terraform" working-directory: "infra/terraform"

View File

@@ -11,7 +11,9 @@
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": { "dependencies": {
"eslint-plugin-react-refresh": "0.4.20" "eslint-plugin-react-refresh": "0.4.20",
"react": "19.1.0",
"react-dom": "19.1.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "3.2.6", "@chromatic-com/storybook": "3.2.6",

View File

@@ -1,79 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ConnectWithFormbricks } from "./ConnectWithFormbricks";
// Mocks before import
const pushMock = vi.fn();
const refreshMock = vi.fn();
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) }));
vi.mock("./OnboardingSetupInstructions", () => ({
OnboardingSetupInstructions: () => <div data-testid="instructions" />,
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("ConnectWithFormbricks", () => {
const environment = { id: "env1" } as any;
const webAppUrl = "http://app";
const channel = {} as any;
test("renders waiting state when widgetSetupCompleted is false", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>
);
expect(screen.getByTestId("instructions")).toBeInTheDocument();
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
});
test("renders success state when widgetSetupCompleted is true", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
);
expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument();
expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument();
});
test("clicking finish button navigates to surveys", async () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
);
const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" });
await userEvent.click(button);
expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`);
});
test("refresh is called on visibilitychange to visible", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>
);
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
expect(refreshMock).toHaveBeenCalled();
});
});

View File

@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>

View File

@@ -1,145 +0,0 @@
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import OnboardingLayout from "./layout";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
describe("OnboardingLayout", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects to login if session is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
await OnboardingLayout({
params: { environmentId: "env1" },
children: <div>Test Content</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("throws AuthorizationError if user lacks access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
OnboardingLayout({
params: { environmentId: "env1" },
children: <div>Test Content</div>,
})
).rejects.toThrow("User is not authorized to access this environment");
});
test("renders children if user has access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
const result = await OnboardingLayout({
params: { environmentId: "env1" },
children: <div data-testid="child">Test Content</div>,
});
render(result);
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
});
});

View File

@@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => {
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId); const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!isAuthorized) { if (!isAuthorized) {
throw new AuthorizationError("User is not authorized to access this environment"); throw AuthorizationError;
} }
return <div className="flex-1 bg-slate-50">{children}</div>; return <div className="flex-1 bg-slate-50">{children}</div>;

View File

@@ -1,76 +0,0 @@
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { XMTemplateList } from "./XMTemplateList";
// Prepare push mock and module mocks before importing component
const pushMock = vi.fn();
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
getXMTemplates: (t: any) => [
{ id: 1, name: "tmpl1" },
{ id: 2, name: "tmpl2" },
],
}));
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
}));
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
<div>
{options.map((opt, idx) => (
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
{opt.title}
</button>
))}
</div>
),
}));
// Reset mocks between tests
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("XMTemplateList component", () => {
const project = { id: "proj1" } as any;
const user = { id: "user1" } as any;
const environmentId = "env1";
test("creates survey and navigates on success", async () => {
// Mock successful survey creation
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
const option0 = screen.getByTestId("option-0");
await userEvent.click(option0);
expect(createSurveyAction).toHaveBeenCalledWith({
environmentId,
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
});
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
});
test("shows error toast on failure", async () => {
// Mock failed survey creation
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
const option1 = screen.getByTestId("option-1");
await userEvent.click(option1);
expect(createSurveyAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("formatted-error");
});
});

View File

@@ -1,80 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
brandColor: { light: "#FFFFFF" },
},
recontactDays: 30,
inAppSurveyBranding: true,
linkSurveyBranding: true,
config: {
channel: "link" as const,
industry: "eCommerce" as "eCommerce" | "saas" | "other" | null,
},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
languages: [],
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
questions: [
{
id: "q1",
inputType: "text",
type: "email" as any,
headline: { default: "$[projectName] Question" },
required: false,
charLimit: { enabled: true, min: 400, max: 1000 },
},
],
endings: [
{
id: "e1",
type: "endScreen",
headline: { default: "Thank you for completing the survey!" },
},
],
styling: {
brandColor: { light: "#0000FF" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
},
};
describe("replacePresetPlaceholders", () => {
afterEach(() => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
});
test("replaces projectName placeholder in question headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.questions[0].headline.default).toBe("Test Project Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});
});

View File

@@ -1,60 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/preact";
import { TFnType } from "@tolgee/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
vi.mock("@formbricks/logger", () => ({
logger: { error: vi.fn() },
}));
describe("xm-templates", () => {
afterEach(() => {
cleanup();
});
test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key) => key) as TFnType;
const result = getXMSurveyDefault(tMock);
expect(result).toEqual({
name: "",
endings: expect.any(Array),
questions: [],
styling: {
overwriteThemeStyling: true,
},
});
expect(result.endings).toHaveLength(1);
});
test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key) => key) as TFnType;
const result = getXMTemplates(tMock);
expect(result).toHaveLength(6);
expect(result[0].name).toBe("templates.nps_survey_name");
expect(result[1].name).toBe("templates.star_rating_survey_name");
expect(result[2].name).toBe("templates.csat_survey_name");
expect(result[3].name).toBe("templates.cess_survey_name");
expect(result[4].name).toBe("templates.smileys_survey_name");
expect(result[5].name).toBe("templates.enps_survey_name");
});
test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => {
throw new Error("Test error");
}) as TFnType;
const result = getXMTemplates(tMock);
// Dynamically import the mocked logger
const { logger } = await import("@formbricks/logger");
expect(result).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Unable to load XM templates, returning empty array"
);
});
});

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -1,58 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getTeamsByOrganizationId } from "./onboarding";
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache", () => ({
cache: (fn: any) => fn,
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
tag: { byOrganizationId: vi.fn((id: string) => `organization-${id}-teams`) },
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("getTeamsByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped teams", async () => {
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");
expect(result).toEqual([
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
test("throws error on unknown error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail"));
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail");
});
});

View File

@@ -1,75 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LandingSidebar } from "./landing-sidebar";
// Module mocks must be declared before importing the component
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => (
<div data-testid={open ? "modal-open" : "modal-closed"} />
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
}));
// Ensure mocks are reset between tests
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [
{ id: "o2", name: "betaOrg" },
{ id: "o1", name: "alphaOrg" },
] as any;
test("renders logo, avatar, and initial modal closed", () => {
render(
<LandingSidebar
isMultiOrgEnabled={false}
user={user}
organization={organization}
organizations={organizations}
/>
);
// Formbricks logo
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
// Profile avatar
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
// CreateOrganizationModal should be closed initially
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
});
test("clicking logout triggers signOut", async () => {
render(
<LandingSidebar
isMultiOrgEnabled={false}
user={user}
organization={organization}
organizations={organizations}
/>
);
// Open user dropdown by clicking on avatar trigger
const trigger = screen.getByTestId("avatar").parentElement;
if (trigger) await userEvent.click(trigger);
// Click logout menu item
const logoutItem = await screen.findByText("common.logout");
await userEvent.click(logoutItem);
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
});
});

View File

@@ -1,185 +0,0 @@
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/preact";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import LandingLayout from "./layout";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/lib/environment/service");
vi.mock("@/lib/membership/service");
vi.mock("@/lib/project/service");
vi.mock("next-auth");
vi.mock("next/navigation");
afterEach(() => {
cleanup();
});
describe("LandingLayout", () => {
test("redirects to login if no session exists", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
});
test("returns notFound if no membership is found", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
test("redirects to production environment if available", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
organizationId: "org-123",
userId: "user-123",
accepted: true,
role: "owner",
});
vi.mocked(getUserProjects).mockResolvedValue([
{
id: "proj-123",
organizationId: "org-123",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-02"),
name: "Project 1",
styling: { allowStyleOverwrite: true },
recontactDays: 30,
inAppSurveyBranding: true,
linkSurveyBranding: true,
} as any,
]);
vi.mocked(getEnvironments).mockResolvedValue([
{
id: "env-123",
type: "production",
projectId: "proj-123",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-02"),
appSetupCompleted: true,
},
]);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
});
test("renders children if no projects or production environment exist", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
organizationId: "org-123",
userId: "user-123",
accepted: true,
role: "owner",
});
vi.mocked(getUserProjects).mockResolvedValue([]);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
const result = await LandingLayout(props);
expect(result).toEqual(
<>
<div>Child Content</div>
</>
);
});
});

View File

@@ -1,198 +0,0 @@
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound, redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
LandingSidebar: () => <div data-testid="landing-sidebar" />,
}));
vi.mock("@/modules/organization/lib/utils");
vi.mock("@/lib/user/service");
vi.mock("@/lib/organization/service");
vi.mock("@/tolgee/server");
vi.mock("next/navigation", () => ({
redirect: vi.fn(() => "REDIRECT_STUB"),
notFound: vi.fn(() => "NOT_FOUND_STUB"),
}));
// Mock the React cache function
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: (fn: any) => fn,
};
});
describe("Page component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.resetModules();
});
test("redirects to login if no user session", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } });
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB");
});
test("returns notFound if user does not exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } },
organization: {},
} as any);
vi.mocked(getUser).mockResolvedValue(null);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } });
expect(notFound).toHaveBeenCalled();
expect(result).toBe("NOT_FOUND_STUB");
});
test("renders header and sidebar for authenticated user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } },
organization: { id: "org1" },
} as any);
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
typeof props === "string" ? props : props.key || ""
);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const element = await Page({ params: { organizationId: "org1" } });
render(element as React.ReactElement);
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { getOrganizationsByUserId } from "@/lib/organization/service"; import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";

View File

@@ -34,7 +34,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("next-auth", () => ({ vi.mock("next-auth", () => ({

View File

@@ -1,88 +0,0 @@
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
const mockTranslate = vi.fn((key) => key);
// Module mocks must be declared before importing the component
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
vi.mock("@/modules/ui/components/header", () => ({
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
<div>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
),
}));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
}));
describe("Page component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const params = Promise.resolve({ organizationId: "org1" });
test("redirects to login if no user session", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
const result = await Page({ params });
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB");
});
test("renders header, options, and close button when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
const element = await Page({ params });
render(element as React.ReactElement);
// Header title and subtitle
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.channel.channel_select_title"
);
expect(
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
).toBeInTheDocument();
// Options container with correct titles
expect(screen.getByTestId("options")).toHaveTextContent(
"organizations.projects.new.channel.link_and_email_surveys," +
"organizations.projects.new.channel.in_product_surveys"
);
// Close button link rendered when projects >=1
const closeLink = screen.getByRole("link");
expect(closeLink).toHaveAttribute("href", "/");
});
test("does not render close button when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValue([]);
const element = await Page({ params });
render(element as React.ReactElement);
expect(screen.queryByRole("link")).toBeNull();
});
});

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -1,221 +0,0 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import OnboardingLayout from "./layout";
// Mock environment variables
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,
SESSION_MAX_AGE: 1000,
}));
// Mock dependencies
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getOrganizationProjectsCount: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
describe("OnboardingLayout", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects to login if no session", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("returns not found if user is member or billing", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "member",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(notFound).toHaveBeenCalled();
});
test("throws error if organization is not found", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getOrganization).mockResolvedValue(null);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
});
test("redirects to home if project limit is reached", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
};
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(redirect).toHaveBeenCalledWith("/");
});
test("renders children when all conditions are met", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
};
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
const result = await OnboardingLayout(props);
expect(result).toEqual(<>{props.children}</>);
});
});

View File

@@ -1,72 +0,0 @@
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
const mockTranslate = vi.fn((key) => key);
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
vi.mock("next/link", () => ({
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: any) => (
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
),
}));
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
describe("Mode Page", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const params = Promise.resolve({ organizationId: "org1" });
test("redirects to login if no session user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
await Page({ params });
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("renders header and options without close link when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
const element = await Page({ params });
render(element as React.ReactElement);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.mode.what_are_you_here_for"
);
expect(screen.getByTestId("options")).toHaveTextContent(
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
);
expect(screen.queryByRole("link")).toBeNull();
});
test("renders close link when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
const element = await Page({ params });
render(element as React.ReactElement);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/");
});
});

View File

@@ -1,124 +0,0 @@
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectSettings } from "./ProjectSettings";
// Mocks before imports
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() }));
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
vi.mock("@/modules/ui/components/color-picker", () => ({
ColorPicker: ({ color, onChange }: any) => (
<button data-testid="color-picker" onClick={() => onChange("#000")}>
{color}
</button>
),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ value, onChange, placeholder }: any) => (
<input placeholder={placeholder} value={value} onChange={(e) => onChange((e.target as any).value)} />
),
}));
vi.mock("@/modules/ui/components/multi-select", () => ({
MultiSelect: ({ value, options, onChange }: any) => (
<select
data-testid="multi-select"
multiple
value={value}
onChange={(e) => onChange(Array.from((e.target as any).selectedOptions).map((o: any) => o.value))}>
{options.map((o: any) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
),
}));
vi.mock("@/modules/ui/components/survey", () => ({
SurveyInline: () => <div data-testid="survey-inline" />,
}));
vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) }));
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
CreateTeamModal: ({ open }: any) => <div data-testid={open ? "team-modal-open" : "team-modal-closed"} />,
}));
// Clean up after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
describe("ProjectSettings component", () => {
const baseProps = {
organizationId: "org1",
projectMode: "cx",
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
canDoRoleManagement: false,
userProjectsCount: 0,
} as any;
const fillAndSubmit = async () => {
const nameInput = screen.getByPlaceholderText("e.g. Formbricks");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "TestProject");
const nextButton = screen.getByRole("button", { name: "common.next" });
await userEvent.click(nextButton);
};
test("successful createProject for link channel navigates to surveys and clears localStorage", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env123", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(createProjectAction).toHaveBeenCalledWith({
organizationId: "org1",
data: expect.objectContaining({ teamIds: [] }),
});
expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys");
expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull();
});
test("successful createProject for app channel navigates to connect", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env456", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="app" projectMode="cx" />);
await fillAndSubmit();
expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect");
});
test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env789", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="unknown" projectMode="cx" />);
await fillAndSubmit();
expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates");
});
test("shows error toast on createProject error response", async () => {
(createProjectAction as any).mockResolvedValue({ error: "err" });
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(toast.error).toHaveBeenCalledWith("formatted-error");
});
test("shows error toast on exception", async () => {
(createProjectAction as any).mockImplementation(() => {
throw new Error("fail");
});
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed");
});
});

View File

@@ -225,7 +225,7 @@ export const ProjectSettings = ({
alt="Logo" alt="Logo"
width={256} width={256}
height={56} height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1" className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/> />
)} )}
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>

View File

@@ -1,106 +0,0 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
vi.mock("next/link", () => ({
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
}));
vi.mock("@/modules/ui/components/header", () => ({
Header: ({ title, subtitle }: any) => (
<div>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock(
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
() => ({
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
})
);
// Cleanup after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("ProjectSettingsPage", () => {
const params = Promise.resolve({ organizationId: "org1" });
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
test("redirects to login when no session user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
await Page({ params, searchParams });
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("throws when teams not found", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
});
test("renders header, settings and close link when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
// Header
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.settings.project_settings_title"
);
// ProjectSettings stub receives mode prop
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
// Close link for existing projects
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/");
});
test("renders without close link when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
expect(screen.queryByRole("link")).toBeNull();
});
});

View File

@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>

View File

@@ -1,106 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Home, Settings } from "lucide-react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
describe("OnboardingOptionsContainer", () => {
afterEach(() => {
cleanup();
});
test("renders options with links", () => {
const options = [
{
title: "Test Option",
description: "Test Description",
icon: Home,
href: "/test",
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Test Option")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
});
test("renders options with onClick handler", () => {
const onClickMock = vi.fn();
const options = [
{
title: "Click Option",
description: "Click Description",
icon: Home,
onClick: onClickMock,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Click Option")).toBeInTheDocument();
expect(screen.getByText("Click Description")).toBeInTheDocument();
});
test("renders options with iconText", () => {
const options = [
{
title: "Icon Text Option",
description: "Icon Text Description",
icon: Home,
iconText: "Custom Icon Text",
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
});
test("renders options with loading state", () => {
const options = [
{
title: "Loading Option",
description: "Loading Description",
icon: Home,
isLoading: true,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Loading Option")).toBeInTheDocument();
});
test("renders multiple options", () => {
const options = [
{
title: "First Option",
description: "First Description",
icon: Home,
},
{
title: "Second Option",
description: "Second Description",
icon: Settings,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("First Option")).toBeInTheDocument();
expect(screen.getByText("Second Option")).toBeInTheDocument();
});
test("calls onClick handler when clicking an option", async () => {
const onClickMock = vi.fn();
const options = [
{
title: "Click Option",
description: "Click Description",
icon: Home,
onClick: onClickMock,
},
];
render(<OnboardingOptionsContainer options={options} />);
await userEvent.click(screen.getByText("Click Option"));
expect(onClickMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -25,7 +25,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host", SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
})); }));
describe("Contact Page Re-export", () => { describe("Contact Page Re-export", () => {

View File

@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500"> <div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)} {timeSince(actionClass.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div> <div className="text-center"></div>

View File

@@ -1,3 +1,4 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { getEnvironment, getEnvironments } from "@/lib/environment/service"; import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
@@ -9,7 +10,7 @@ import {
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
@@ -48,6 +49,7 @@ vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
})); }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getEnterpriseLicense: vi.fn(),
getOrganizationProjectsLimit: vi.fn(), getOrganizationProjectsLimit: vi.fn(),
})); }));
vi.mock("@/modules/ee/teams/lib/roles", () => ({ vi.mock("@/modules/ee/teams/lib/roles", () => ({
@@ -174,6 +176,7 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100); vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500); vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission); vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
mockIsDevelopment = false; mockIsDevelopment = false;
@@ -186,19 +189,13 @@ describe("EnvironmentLayout", () => {
}); });
test("renders correctly with default props", async () => { test("renders correctly with default props", async () => {
vi.resetModules(); // Ensure the default mockLicense has isPendingDowngrade: false and active: false
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ vi.mocked(getEnterpriseLicense).mockResolvedValue({
getEnterpriseLicense: vi.fn().mockResolvedValue({ ...mockLicense,
active: false, isPendingDowngrade: false,
isPendingDowngrade: false, active: false,
features: { isMultiOrgEnabled: false }, });
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -206,31 +203,20 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("main-navigation")).toBeInTheDocument(); expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument(); expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
expect(screen.getByText("Child Content")).toBeInTheDocument(); expect(screen.getByText("Child Content")).toBeInTheDocument();
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument(); expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument(); expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
}); });
test("renders DevEnvironmentBanner in development environment", async () => { test("renders DevEnvironmentBanner in development environment", async () => {
const devEnvironment = { ...mockEnvironment, type: "development" as const }; const devEnvironment = { ...mockEnvironment, type: "development" as const };
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment); vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
mockIsDevelopment = true; mockIsDevelopment = true;
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -238,24 +224,13 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("dev-banner")).toBeInTheDocument(); expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
}); });
test("renders LimitsReachedBanner in Formbricks Cloud", async () => { test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
mockIsFormbricksCloud = true; mockIsFormbricksCloud = true;
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -263,21 +238,17 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("limits-banner")).toBeInTheDocument(); expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id); expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id); expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
}); });
test("renders PendingDowngradeBanner when pending downgrade", async () => { test("renders PendingDowngradeBanner when pending downgrade", async () => {
// Ensure the license mock reflects the condition needed for the banner
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true }; const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render( render(
await EnvironmentLayout({ await EnvironmentLayout({
environmentId: "env-1", environmentId: "env-1",
@@ -285,24 +256,12 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>, children: <div>Child Content</div>,
}) })
); );
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument(); expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
}); });
test("throws error if user not found", async () => { test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null); vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.user_not_found" "common.user_not_found"
); );
@@ -310,19 +269,6 @@ describe("EnvironmentLayout", () => {
test("throws error if organization not found", async () => { test("throws error if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.organization_not_found" "common.organization_not_found"
); );
@@ -330,39 +276,13 @@ describe("EnvironmentLayout", () => {
test("throws error if environment not found", async () => { test("throws error if environment not found", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null); vi.mocked(getEnvironment).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.environment_not_found" "common.environment_not_found"
); );
}); });
test("throws error if projects, environments or organizations not found", async () => { test("throws error if projects, environments or organizations not found", async () => {
vi.mocked(getUserProjects).mockResolvedValue(null as any); vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"environments.projects_environments_organizations_not_found" "environments.projects_environments_organizations_not_found"
); );
@@ -371,19 +291,6 @@ describe("EnvironmentLayout", () => {
test("throws error if member has no project permission", async () => { test("throws error if member has no project permission", async () => {
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any); vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null); vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow( await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.project_permission_not_found" "common.project_permission_not_found"
); );

View File

@@ -12,8 +12,7 @@ import {
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";

View File

@@ -109,7 +109,7 @@ export const MainNavigation = ({
useEffect(() => { useEffect(() => {
const toggleTextOpacity = () => { const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed); setIsTextVisible(isCollapsed ? true : false);
}; };
const timeoutId = setTimeout(toggleTextOpacity, 150); const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
@@ -170,7 +170,7 @@ export const MainNavigation = ({
name: t("common.actions"), name: t("common.actions"),
href: `/environments/${environment.id}/actions`, href: `/environments/${environment.id}/actions`,
icon: MousePointerClick, icon: MousePointerClick,
isActive: pathname?.includes("/actions"), isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
}, },
{ {
name: t("common.integrations"), name: t("common.integrations"),

View File

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p> <p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />

View File

@@ -48,7 +48,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_CLIENT_SECRET: "test-oidc-client-secret", OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("@/lib/integration/service"); vi.mock("@/lib/integration/service");

View File

@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="Surveys">{t("common.questions")}</Label> <Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200"> <div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900"> <div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2"> <div key={question.id} className="my-1 flex items-center space-x-2">

View File

@@ -31,7 +31,6 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret", GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url", GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
})); }));
// Mock child components // Mock child components

View File

@@ -24,7 +24,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("AppConnectionPage Re-export", () => { describe("AppConnectionPage Re-export", () => {

View File

@@ -24,7 +24,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("GeneralSettingsPage re-export", () => { describe("GeneralSettingsPage re-export", () => {

View File

@@ -24,7 +24,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("LanguagesPage re-export", () => { describe("LanguagesPage re-export", () => {

View File

@@ -24,7 +24,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("ProjectLookSettingsPage re-export", () => { describe("ProjectLookSettingsPage re-export", () => {

View File

@@ -24,7 +24,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("TagsPage re-export", () => { describe("TagsPage re-export", () => {

View File

@@ -24,7 +24,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
describe("ProjectTeams re-export", () => { describe("ProjectTeams re-export", () => {

View File

@@ -40,7 +40,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
})); }));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId); const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);

View File

@@ -1,80 +1,17 @@
"use server"; "use server";
import {
checkUserExistsByEmail,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service"; import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils"; import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { rateLimit } from "@/lib/utils/rate-limit";
import { sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { import { ZUserUpdateInput } from "@formbricks/types/user";
AuthenticationError,
AuthorizationError,
InvalidInputError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
export const updateUserAction = authenticatedActionClient export const updateUserAction = authenticatedActionClient
.schema( .schema(ZUserUpdateInput.partial())
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(async ({ parsedInput, ctx }) => { .action(async ({ parsedInput, ctx }) => {
const inputEmail = parsedInput.email?.trim().toLowerCase(); return await updateUser(ctx.user.id, parsedInput);
let payload: TUserUpdateInput = {
name: parsedInput.name,
locale: parsedInput.locale,
};
if (inputEmail && ctx.user.email !== inputEmail) {
// Check rate limit
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
const doesUserExist = await checkUserExistsByEmail(inputEmail);
if (doesUserExist) {
throw new InvalidInputError("This email is already in use");
}
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
}
return await updateUser(ctx.user.id, payload);
}); });
const ZUpdateAvatarAction = z.object({ const ZUpdateAvatarAction = z.object({

View File

@@ -50,10 +50,11 @@ describe("EditProfileDetailsForm", () => {
test("renders with initial user data and updates successfully", async () => { test("renders with initial user data and updates successfully", async () => {
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any); vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />); render(<EditProfileDetailsForm user={mockUser} />);
const nameInput = screen.getByPlaceholderText("common.full_name"); const nameInput = screen.getByPlaceholderText("common.full_name");
expect(nameInput).toHaveValue(mockUser.name); expect(nameInput).toHaveValue(mockUser.name);
expect(screen.getByDisplayValue(mockUser.email)).toBeDisabled();
// Check initial language (English) // Check initial language (English)
expect(screen.getByText("English (US)")).toBeInTheDocument(); expect(screen.getByText("English (US)")).toBeInTheDocument();
@@ -71,11 +72,7 @@ describe("EditProfileDetailsForm", () => {
await userEvent.click(updateButton); await userEvent.click(updateButton);
await waitFor(() => { await waitFor(() => {
expect(updateUserAction).toHaveBeenCalledWith({ expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" });
name: "New Name",
locale: "de-DE",
email: mockUser.email,
});
}); });
await waitFor(() => { await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith( expect(toast.success).toHaveBeenCalledWith(
@@ -91,7 +88,7 @@ describe("EditProfileDetailsForm", () => {
const errorMessage = "Update failed"; const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage)); vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />); render(<EditProfileDetailsForm user={mockUser} />);
const nameInput = screen.getByPlaceholderText("common.full_name"); const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput); await userEvent.clear(nameInput);
@@ -109,7 +106,7 @@ describe("EditProfileDetailsForm", () => {
}); });
test("update button is disabled initially and enables on change", async () => { test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />); render(<EditProfileDetailsForm user={mockUser} />);
const updateButton = screen.getByText("common.update"); const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled(); expect(updateButton).toBeDisabled();

View File

@@ -1,8 +1,6 @@
"use client"; "use client";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils"; import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -10,214 +8,129 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react"; import { ChevronDownIcon } from "lucide-react";
import { signOut } from "next-auth/react"; import { SubmitHandler, useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user"; import { TUser, ZUser } from "@formbricks/types/user";
import { updateUserAction } from "../actions"; import { updateUserAction } from "../actions";
// Schema & types const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true });
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true });
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>; type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
export const EditProfileDetailsForm = ({ export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
user,
emailVerificationDisabled,
}: {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate();
const router = useRouter();
const form = useForm<TEditProfileNameForm>({ const form = useForm<TEditProfileNameForm>({
defaultValues: { defaultValues: { name: user.name, locale: user.locale || "en" },
name: user.name,
locale: user.locale,
email: user.email,
},
mode: "onChange", mode: "onChange",
resolver: zodResolver(ZEditProfileNameFormSchema), resolver: zodResolver(ZEditProfileNameFormSchema),
}); });
const { isSubmitting, isDirty } = form.formState; const { isSubmitting, isDirty } = form.formState;
const [showModal, setShowModal] = useState(false); const { t } = useTranslate();
const handleConfirmPassword = async (password: string) => {
const values = form.getValues();
const dirtyFields = form.formState.dirtyFields;
const emailChanged = "email" in dirtyFields;
const nameChanged = "name" in dirtyFields;
const localeChanged = "locale" in dirtyFields;
const name = values.name.trim();
const email = values.email.trim().toLowerCase();
const locale = values.locale;
const data: TUserUpdateInput = {};
if (emailChanged) {
data.email = email;
data.password = password;
}
if (nameChanged) {
data.name = name;
}
if (localeChanged) {
data.locale = locale;
}
const updatedUserResult = await updateUserAction(data);
if (updatedUserResult?.data) {
if (!emailVerificationDisabled) {
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
} else {
toast.success(t("environments.settings.profile.profile_updated_successfully"));
await signOut({ redirect: false });
router.push(`/email-change-without-verification-success`);
return;
}
} else {
const errorMessage = getFormattedErrorMessage(updatedUserResult);
toast.error(errorMessage);
return;
}
window.location.reload();
setShowModal(false);
};
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => { const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
if (data.email !== user.email && data.email.toLowerCase() === user.email.toLowerCase()) { try {
toast.error(t("auth.email-change.email_already_exists")); const name = data.name.trim();
return; const locale = data.locale;
} await updateUserAction({ name, locale });
toast.success(t("environments.settings.profile.profile_updated_successfully"));
if (data.email !== user.email) { window.location.reload();
setShowModal(true); form.reset({ name, locale });
} else { } catch (error) {
try { toast.error(`${t("common.error")}: ${error.message}`);
await updateUserAction({
...data,
name: data.name.trim(),
});
toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload();
form.reset(data);
} catch (error: any) {
toast.error(`${t("common.error")}: ${error.message}`);
}
} }
}; };
return ( return (
<> <FormProvider {...form}>
<FormProvider {...form}> <form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
<form className="w-full max-w-sm" onSubmit={form.handleSubmit(onSubmit)}> <FormField
<FormField control={form.control}
control={form.control} name="name"
name="name" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>{t("common.full_name")}</FormLabel>
<FormLabel>{t("common.full_name")}</FormLabel> <FormControl>
<FormControl> <Input
<Input {...field}
{...field} type="text"
type="text" placeholder={t("common.full_name")}
required required
placeholder={t("common.full_name")} isInvalid={!!form.formState.errors.name}
isInvalid={!!form.formState.errors.name} />
/> </FormControl>
</FormControl> <FormError />
<FormError /> </FormItem>
</FormItem> )}
)} />
/>
<FormField {/* disabled email field */}
control={form.control} <div className="mt-4 space-y-2">
name="email" <Label htmlFor="email">{t("common.email")}</Label>
render={({ field }) => ( <Input type="email" id="email" defaultValue={user.email} disabled />
<FormItem className="mt-4"> </div>
<FormLabel>{t("common.email")}</FormLabel>
<FormControl>
<Input
{...field}
type="email"
required
isInvalid={!!form.formState.errors.email}
disabled={user.identityProvider !== "email"}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="locale" name="locale"
render={({ field }) => ( render={({ field }) => (
<FormItem className="mt-4"> <FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel> <FormLabel>{t("common.language")}</FormLabel>
<FormControl> <FormControl>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
variant="ghost" className="h-10 w-full border border-slate-300 px-3 text-left"
className="h-10 w-full border border-slate-300 px-3 text-left"> variant="ghost">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label[field.value] ?? "NA"} {appLanguages.find((language) => language.code === field.value)?.label[field.value] ||
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> "NA"}
</div> <ChevronDownIcon className="h-4 w-4 text-slate-500" />
</Button> </div>
</DropdownMenuTrigger> </Button>
<DropdownMenuContent className="w-40 bg-slate-50 text-slate-700" align="start"> </DropdownMenuTrigger>
{appLanguages.map((lang) => ( <DropdownMenuContent
<DropdownMenuItem className="w-40 bg-slate-50 text-slate-700"
key={lang.code} align="start"
onClick={() => field.onChange(lang.code)} side="bottom">
className="min-h-8 cursor-pointer"> {appLanguages.map((language) => (
{lang.label[field.value]} <DropdownMenuItem
</DropdownMenuItem> key={language.code}
))} onClick={() => field.onChange(language.code)}
</DropdownMenuContent> className="min-h-8 cursor-pointer">
</DropdownMenu> {language.label[field.value]}
</FormControl> </DropdownMenuItem>
<FormError /> ))}
</FormItem> </DropdownMenuContent>
)} </DropdownMenu>
/> </FormControl>
<FormError />
</FormItem>
)}
/>
<Button <Button
type="submit" type="submit"
className="mt-4" className="mt-4"
size="sm" size="sm"
loading={isSubmitting} loading={isSubmitting}
disabled={isSubmitting || !isDirty}> disabled={isSubmitting || !isDirty}>
{t("common.update")} {t("common.update")}
</Button> </Button>
</form> </form>
</FormProvider> </FormProvider>
<PasswordConfirmationModal
open={showModal}
setOpen={setShowModal}
oldEmail={user.email}
newEmail={form.getValues("email") || user.email}
onConfirm={handleConfirmPassword}
/>
</>
); );
}; };

View File

@@ -1,132 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordConfirmationModal } from "./password-confirmation-modal";
// Mock the Modal component
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, title }: any) =>
open ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
<button data-testid="modal-close" onClick={() => setOpen(false)}>
Close
</button>
</div>
) : null,
}));
// Mock the PasswordInput component
vi.mock("@/modules/ui/components/password-input", () => ({
PasswordInput: ({ onChange, value, placeholder }: any) => (
<input
type="password"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
data-testid="password-input"
/>
),
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("PasswordConfirmationModal", () => {
const defaultProps = {
open: true,
setOpen: vi.fn(),
oldEmail: "old@example.com",
newEmail: "new@example.com",
onConfirm: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("renders modal content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
});
test("displays old and new email addresses", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByText("old@example.com")).toBeInTheDocument();
expect(screen.getByText("new@example.com")).toBeInTheDocument();
});
test("shows password input field", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
expect(passwordInput).toBeInTheDocument();
expect(passwordInput).toHaveAttribute("placeholder", "*******");
});
test("disables confirm button when form is not dirty", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).toBeDisabled();
});
test("disables confirm button when old and new emails are the same", () => {
render(
<PasswordConfirmationModal {...defaultProps} oldEmail="same@example.com" newEmail="same@example.com" />
);
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).toBeDisabled();
});
test("enables confirm button when password is entered and emails are different", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "password123");
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).not.toBeDisabled();
});
test("shows error message when password is too short", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "short");
const confirmButton = screen.getByText("common.confirm");
await user.click(confirmButton);
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
});
test("handles cancel button click and resets form", async () => {
const user = userEvent.setup();
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
await user.type(passwordInput, "password123");
const cancelButton = screen.getByText("common.cancel");
await user.click(cancelButton);
expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
await waitFor(() => {
expect(passwordInput).toHaveValue("");
});
});
});

View File

@@ -1,117 +0,0 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Modal } from "@/modules/ui/components/modal";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
import { ZUserPassword } from "@formbricks/types/user";
interface PasswordConfirmationModalProps {
open: boolean;
setOpen: (open: boolean) => void;
oldEmail: string;
newEmail: string;
onConfirm: (password: string) => Promise<void>;
}
const PasswordConfirmationSchema = z.object({
password: ZUserPassword,
});
type FormValues = z.infer<typeof PasswordConfirmationSchema>;
export const PasswordConfirmationModal = ({
open,
setOpen,
oldEmail,
newEmail,
onConfirm,
}: PasswordConfirmationModalProps) => {
const { t } = useTranslate();
const form = useForm<FormValues>({
resolver: zodResolver(PasswordConfirmationSchema),
});
const { isSubmitting, isDirty } = form.formState;
const onSubmit: SubmitHandler<FormValues> = async (data) => {
try {
await onConfirm(data.password);
form.reset();
} catch (error) {
form.setError("password", {
message: error instanceof Error ? error.message : "Authentication failed",
});
}
};
const handleCancel = () => {
form.reset();
setOpen(false);
};
return (
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("auth.email-change.confirm_password_description")}
</p>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 space-x-2 text-right">
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
);
};

View File

@@ -1,146 +0,0 @@
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkUserExistsByEmail, verifyUserPassword } from "./user";
// Mock dependencies
vi.mock("@/lib/user/cache", () => ({
userCache: {
tag: {
byId: vi.fn((id) => `user-${id}-tag`),
byEmail: vi.fn((email) => `user-email-${email}-tag`),
},
},
}));
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
// reactCache (from "react") and unstable_cache (from "next/cache") are mocked in vitestSetup.ts
// to be pass-through, so the inner logic of cached functions is tested.
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("checkUserExistsByEmail", () => {
const email = "test@example.com";
test("should return true if user exists", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
id: "some-user-id",
} as any);
const result = await checkUserExistsByEmail(email);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
test("should return false if user does not exist", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
const result = await checkUserExistsByEmail(email);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
});
});

View File

@@ -1,70 +0,0 @@
import { cache } from "@/lib/cache";
import { userCache } from "@/lib/user/cache";
import { verifyPassword } from "@/modules/auth/lib/utils";
import { User } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getUserById = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> =>
cache(
async () => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
password: true,
identityProvider: true,
},
});
if (!user) {
throw new ResourceNotFoundError("user", userId);
}
return user;
},
[`getUserById-${userId}`],
{
tags: [userCache.tag.byId(userId)],
}
)()
);
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserById(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
};
export const checkUserExistsByEmail = reactCache(
async (email: string): Promise<boolean> =>
cache(
async () => {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
id: true,
},
});
return !!user;
},
[`checkUserExistsByEmail-${email}`],
{
tags: [userCache.tag.byEmail(email)],
}
)()
);

View File

@@ -13,7 +13,6 @@ import Page from "./page";
// Mock services and utils // Mock services and utils
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, IS_FORMBRICKS_CLOUD: true,
EMAIL_VERIFICATION_DISABLED: true,
})); }));
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(), getOrganizationsWhereUserIsSingleOwner: vi.fn(),

View File

@@ -1,6 +1,6 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -42,7 +42,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
<SettingsCard <SettingsCard
title={t("environments.settings.profile.personal_information")} title={t("environments.settings.profile.personal_information")}
description={t("environments.settings.profile.update_personal_info")}> description={t("environments.settings.profile.update_personal_info")}>
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} /> <EditProfileDetailsForm user={user} />
</SettingsCard> </SettingsCard>
<SettingsCard <SettingsCard
title={t("common.avatar")} title={t("common.avatar")}

View File

@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships"; import { TMembership } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations"; import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import EnterpriseSettingsPage from "./page";
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
@@ -178,23 +179,15 @@ describe("EnterpriseSettingsPage", () => {
}); });
test("renders correctly for an owner when not on Formbricks Cloud", async () => { test("renders correctly for an owner when not on Formbricks Cloud", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { default: EnterpriseSettingsPage } = await import("./page");
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } }); const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
render(Page); render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument(); expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument(); expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument(); expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument(); expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
expect(redirect).not.toHaveBeenCalled(); expect(redirect).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -118,7 +118,7 @@ const Page = async (props) => {
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg <svg
viewBox="0 0 1024 1024" viewBox="0 0 1024 1024"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0" className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true"> aria-hidden="true">
<circle <circle
cx={512} cx={512}

View File

@@ -29,7 +29,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: 587, SMTP_PORT: 587,
SMTP_USER: "mock-smtp-user", SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password", SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
})); }));
describe("TeamsPage re-export", () => { describe("TeamsPage re-export", () => {

View File

@@ -31,7 +31,7 @@ export const SettingsCard = ({
id={title}> id={title}>
<div className="border-b border-slate-200 px-4 pb-4"> <div className="border-b border-slate-200 px-4 pb-4">
<div className="flex"> <div className="flex">
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3> <h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3>
<div className="ml-2"> <div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />} {beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && ( {soon && (

View File

@@ -45,7 +45,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: 587, SMTP_PORT: 587,
SMTP_USER: "mock-smtp-user", SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password", SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"); vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");

View File

@@ -1,494 +1,487 @@
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable"; import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import type { DragEndEvent } from "@dnd-kit/core";
import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast"; import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses"; import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user"; import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "./ResponseTable";
// Mock react-hot-toast // Hoist variables used in mock factories
vi.mock("react-hot-toast", () => ({ const { DndContextMock, SortableContextMock, arrayMoveMock } = vi.hoisted(() => {
default: { const dndMock = vi.fn(({ children, onDragEnd }) => {
error: vi.fn(), // Store the onDragEnd prop to allow triggering it in tests
success: vi.fn(), (dndMock as any).lastOnDragEnd = onDragEnd;
dismiss: vi.fn(), 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,
};
});
// Mock components vi.mock("@dnd-kit/core", async (importOriginal) => {
vi.mock("@/modules/ui/components/button", () => ({ const actual = await importOriginal<typeof import("@dnd-kit/core")>();
Button: ({ children, onClick, ...props }: any) => ( return {
<button onClick={onClick} data-testid="button" {...props}> ...actual,
{children} DndContext: DndContextMock,
</button> useSensor: vi.fn(),
), useSensors: vi.fn(),
})); closestCenter: vi.fn(),
};
// Mock DndContext/SortableContext });
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: any) => <div>{children}</div>,
useSensor: vi.fn(),
useSensors: vi.fn(() => "sensors"),
closestCenter: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
KeyboardSensor: vi.fn(),
}));
vi.mock("@dnd-kit/modifiers", () => ({ vi.mock("@dnd-kit/modifiers", () => ({
restrictToHorizontalAxis: "restrictToHorizontalAxis", restrictToHorizontalAxis: vi.fn(),
})); }));
vi.mock("@dnd-kit/sortable", () => ({ vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: any) => <>{children}</>, SortableContext: SortableContextMock,
horizontalListSortingStrategy: "horizontalListSortingStrategy", arrayMove: arrayMoveMock,
arrayMove: vi.fn((arr, oldIndex, newIndex) => { horizontalListSortingStrategy: vi.fn(),
const result = [...arr];
const [removed] = result.splice(oldIndex, 1);
result.splice(newIndex, 0, removed);
return result;
}),
}));
// Mock AutoAnimate
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [vi.fn()],
}));
// Mock UI components
vi.mock("@/modules/ui/components/data-table", () => ({
DataTableHeader: ({ header }: any) => <th data-testid={`header-${header.id}`}>{header.id}</th>,
DataTableSettingsModal: ({ open, setOpen }: any) =>
open ? (
<div data-testid="settings-modal">
Settings Modal <button onClick={() => setOpen(false)}>Close</button>
</div>
) : null,
DataTableToolbar: ({
table,
deleteRowsAction,
downloadRowsAction,
setIsTableSettingsModalOpen,
setIsExpanded,
isExpanded,
}: any) => (
<div data-testid="table-toolbar">
<button
data-testid="toggle-expand"
onClick={() => setIsExpanded(!isExpanded)}
aria-pressed={isExpanded}>
Toggle Expand
</button>
<button data-testid="open-settings" onClick={() => setIsTableSettingsModalOpen(true)}>
Open Settings
</button>
<button
data-testid="delete-rows"
onClick={() => deleteRowsAction(Object.keys(table.getState().rowSelection))}>
Delete Selected
</button>
<button
data-testid="download-csv"
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "csv")}>
Download CSV
</button>
<button
data-testid="download-xlsx"
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "xlsx")}>
Download XLSX
</button>
</div>
),
})); }));
// Mock child components and hooks
vi.mock( vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal", "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
() => ({ () => ({
ResponseCardModal: ({ open, setOpen }: any) => ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) =>
open ? ( open ? (
<div data-testid="response-modal"> <div data-testid="response-card-modal">
Response Modal <button onClick={() => setOpen(false)}>Close</button> Selected Response ID: {selectedResponseId}
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
</div> </div>
) : null, ) : null
),
}) })
); );
vi.mock( vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell", "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
() => ({ () => ({
ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => ( ResponseTableCell: vi.fn(({ cell, row, setSelectedResponseId }) => (
<td data-testid={`cell-${cell.id}-${row.id}`} onClick={() => setSelectedResponseId(row.id)}> <td data-testid={`cell-${cell.id}`} onClick={() => setSelectedResponseId(row.original.responseId)}>
Cell Content {typeof cell.getValue === "function" ? cell.getValue() : JSON.stringify(cell.getValue())}
</td> </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( vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns", "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns",
() => ({ () => ({
generateResponseTableColumns: vi.fn(() => [ generateResponseTableColumns: vi.fn(() => mockGeneratedColumns),
{ id: "select", accessorKey: "select", header: "Select" },
{ id: "createdAt", accessorKey: "createdAt", header: "Created At" },
{ id: "person", accessorKey: "person", header: "Person" },
{ id: "status", accessorKey: "status", header: "Status" },
]),
}) })
); );
vi.mock("@/modules/ui/components/table", () => ({
Table: ({ children, ...props }: any) => <table {...props}>{children}</table>,
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
TableHeader: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
}));
vi.mock("@/modules/ui/components/skeleton", () => ({
Skeleton: ({ children }: any) => <div data-testid="skeleton">{children}</div>,
}));
// Mock the actions
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getResponsesDownloadUrlAction: vi.fn(),
}));
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({ vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
deleteResponseAction: vi.fn(), deleteResponseAction: vi.fn(),
})); }));
// Mock helper functions vi.mock("@/modules/ui/components/data-table", async (importOriginal) => {
vi.mock("@/lib/utils/helper", () => ({ const actual = await importOriginal<typeof import("@/modules/ui/components/data-table")>();
getFormattedErrorMessage: vi.fn(), 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()]),
})); }));
// Mock localStorage vi.mock("@tolgee/react", () => ({
const mockLocalStorage = (() => { useTranslate: () => ({
t: vi.fn((key) => key), // Simple pass-through mock
}),
}));
const localStorageMock = (() => {
let store: Record<string, string> = {}; let store: Record<string, string> = {};
return { return {
getItem: vi.fn((key) => store[key] || null), getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key, value) => { setItem: vi.fn((key: string, value: string) => {
store[key] = String(value); store[key] = value.toString();
}), }),
clear: vi.fn(() => { clear: () => {
store = {}; store = {};
}), },
removeItem: vi.fn((key) => { removeItem: vi.fn((key: string) => {
delete store[key]; delete store[key];
}), }),
}; };
})(); })();
Object.defineProperty(window, "localStorage", { value: mockLocalStorage }); Object.defineProperty(window, "localStorage", { value: localStorageMock });
// Mock Tolgee const mockSurvey = {
vi.mock("@tolgee/react", () => ({ id: "survey1",
useTranslate: () => ({ name: "Test Survey",
t: (key: string) => key, 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;
// Define mock data for tests const mockResponses: TResponse[] = [
const mockProps = { {
data: [ id: "res1",
{ responseId: "resp1", createdAt: new Date().toISOString(), status: "completed", person: "Person 1" }, surveyId: "survey1",
{ responseId: "resp2", createdAt: new Date().toISOString(), status: "completed", person: "Person 2" }, finished: true,
] as any[], data: { q1: "Response 1 Text" },
survey: { createdAt: new Date("2023-01-01T10:00:00.000Z"),
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
name: "name", meta: {},
type: "link", singleUseId: null,
environmentId: "env-1", ttc: {},
createdBy: null, tags: [],
status: "draft", notes: [],
} as TSurvey, variables: {},
responses: [ language: "en",
{ id: "resp1", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() }, contact: null,
{ id: "resp2", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() }, contactAttributes: null,
] as TResponse[], },
environment: { id: "env1" } as TEnvironment, {
environmentTags: [] as TTag[], 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, isReadOnly: false,
fetchNextPage: vi.fn(), fetchNextPage: vi.fn(),
hasMore: false, hasMore: true,
deleteResponses: vi.fn(), deleteResponses: vi.fn(),
updateResponse: vi.fn(), updateResponse: vi.fn(),
isFetchingFirstPage: false, isFetchingFirstPage: false,
locale: "en" as TUserLocale, locale: mockLocale,
}; };
// Setup a container for React Testing Library before each test
beforeEach(() => {
const container = document.createElement("div");
container.id = "test-container";
document.body.appendChild(container);
// Reset all toast mocks before each test
vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear();
// Create a mock anchor element for download tests
const mockAnchor = {
href: "",
click: vi.fn(),
style: {},
};
// Update how we mock the document methods to avoid infinite recursion
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (tagName === "a") return mockAnchor as any;
return originalCreateElement(tagName);
});
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
});
// Cleanup after each test
afterEach(() => {
const container = document.getElementById("test-container");
if (container) {
document.body.removeChild(container);
}
cleanup();
vi.restoreAllMocks(); // Restore mocks after each test
});
describe("ResponseTable", () => { describe("ResponseTable", () => {
afterEach(() => { afterEach(() => {
cleanup(); // Keep cleanup within describe as per instructions cleanup();
localStorageMock.clear();
vi.clearAllMocks();
}); });
test("renders the table with data", () => { test("renders skeleton when isFetchingFirstPage is true", () => {
const container = document.getElementById("test-container"); render(<ResponseTable {...defaultProps} isFetchingFirstPage={true} />);
render(<ResponseTable {...mockProps} />, { container: container! }); // Check for skeleton elements (implementation detail, might need adjustment)
expect(screen.getByRole("table")).toBeInTheDocument(); // For now, check that data is not directly rendered
expect(screen.getByTestId("table-toolbar")).toBeInTheDocument(); expect(screen.queryByText("Response 1 Text")).not.toBeInTheDocument();
// Check if table headers are still there
expect(screen.getByText("Created At")).toBeInTheDocument();
}); });
test("renders no results message when data is empty", () => { test("loads settings from localStorage on mount", () => {
const container = document.getElementById("test-container"); const savedOrder = ["q1", "createdAt", "select"];
render(<ResponseTable {...mockProps} data={[]} responses={[]} />, { container: container! }); 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(); expect(screen.getByText("common.no_results")).toBeInTheDocument();
}); });
test("renders load more button when hasMore is true", () => { test("deleteResponse function calls deleteResponseAction", async () => {
const container = document.getElementById("test-container"); render(<ResponseTable {...defaultProps} />);
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! }); // This function is called by DataTableToolbar's deleteAction prop
expect(screen.getByText("common.load_more")).toBeInTheDocument(); // 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" });
test("calls fetchNextPage when load more button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
const loadMoreButton = screen.getByText("common.load_more");
await userEvent.click(loadMoreButton);
expect(mockProps.fetchNextPage).toHaveBeenCalledTimes(1);
});
test("opens settings modal when toolbar button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const openSettingsButton = screen.getByTestId("open-settings");
await userEvent.click(openSettingsButton);
expect(screen.getByTestId("settings-modal")).toBeInTheDocument();
});
test("toggles expanded state when toolbar button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const toggleExpandButton = screen.getByTestId("toggle-expand");
// Initially might be null, first click should set it to true
await userEvent.click(toggleExpandButton);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith("survey1-rowExpand", expect.any(String));
});
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.csv",
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "csv",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.csv");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.xlsx",
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadXlsxButton = screen.getByTestId("download-xlsx");
await userEvent.click(downloadXlsxButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "xlsx",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.xlsx");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
// Test response modal
test("opens and closes response modal when a cell is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const cell = screen.getByTestId("cell-resp1_select-resp1");
await userEvent.click(cell);
expect(screen.getByTestId("response-modal")).toBeInTheDocument();
// Close the modal
const closeButton = screen.getByText("Close");
await userEvent.click(closeButton);
// Modal should be closed now
expect(screen.queryByTestId("response-modal")).not.toBeInTheDocument();
});
test("shows error toast when download action returns error", async () => {
const errorMsg = "Download failed";
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
serverError: errorMsg,
});
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(errorMsg);
// Reset document.createElement spy to fix the last test
vi.mocked(document.createElement).mockClear();
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("shows default error toast when download action returns no data", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
});
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("");
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("shows error toast when download action throws exception", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockRejectedValueOnce(new Error("Network error"));
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("does not create download link when download action fails", async () => {
// Clear any previous calls to document.createElement
vi.mocked(document.createElement).mockClear();
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
serverError: "Download failed",
});
// Create a fresh spy for createElement for this test only
const createElementSpy = vi.spyOn(document, "createElement");
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
// Check specifically for "a" element creation, not any element
expect(createElementSpy).not.toHaveBeenCalledWith("a");
});
});
test("loads saved settings from localStorage on mount", () => {
const columnOrder = ["status", "person", "createdAt", "select"];
const columnVisibility = { status: false };
const isExpanded = true;
mockLocalStorage.getItem.mockImplementation((key) => {
if (key === "survey1-columnOrder") return JSON.stringify(columnOrder);
if (key === "survey1-columnVisibility") return JSON.stringify(columnVisibility);
if (key === "survey1-rowExpand") return JSON.stringify(isExpanded);
return null;
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
// Verify localStorage calls
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnOrder");
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnVisibility");
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-rowExpand");
// The mock for generateResponseTableColumns returns this order:
// ["select", "createdAt", "person", "status"]
// Only visible columns should be rendered, in this order
const expectedHeaders = ["select", "createdAt", "person"];
const headers = screen.getAllByTestId(/^header-/);
expect(headers).toHaveLength(expectedHeaders.length);
expectedHeaders.forEach((columnId, index) => {
expect(headers[index]).toHaveAttribute("data-testid", `header-${columnId}`);
});
// Verify column visibility is applied
const statusHeader = screen.queryByTestId("header-status");
expect(statusHeader).not.toBeInTheDocument();
// Verify row expansion is applied
const toggleExpandButton = screen.getByTestId("toggle-expand");
expect(toggleExpandButton).toHaveAttribute("aria-pressed", "true");
}); });
}); });

View File

@@ -3,7 +3,6 @@
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal"; import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell"; import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns"; import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions"; import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
@@ -26,16 +25,15 @@ import {
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Sentry from "@sentry/nextjs";
import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses"; import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { TUserLocale } from "@formbricks/types/user";
interface ResponseTableProps { interface ResponseTableProps {
data: TResponseTableData[]; data: TResponseTableData[];
@@ -182,32 +180,6 @@ export const ResponseTable = ({
await deleteResponseAction({ responseId }); await deleteResponseAction({ responseId });
}; };
// Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: format,
filterCriteria: { responseIds },
});
if (downloadResponse?.data) {
const link = document.createElement("a");
link.href = downloadResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
} catch (error) {
Sentry.captureException(error);
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
};
return ( return (
<div> <div>
<DndContext <DndContext
@@ -221,10 +193,9 @@ export const ResponseTable = ({
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen} setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false} isExpanded={isExpanded ?? false}
table={table} table={table}
deleteRowsAction={deleteResponses} deleteRows={deleteResponses}
type="response" type="response"
deleteAction={deleteResponse} deleteAction={deleteResponse}
downloadRowsAction={downloadSelectedRows}
/> />
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200"> <div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">

View File

@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
<button <button
type="button" type="button"
aria-label="Expand response" aria-label="Expand response"
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex" className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
onClick={handleCellClick}> onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" /> <Maximize2Icon className="h-4 w-4" />
</button> </button>

View File

@@ -1,10 +1,6 @@
import { processResponseData } from "@/lib/responses"; import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table"; import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses"; import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import { import {
@@ -261,238 +257,3 @@ describe("generateResponseTableColumns", () => {
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]); expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
}); });
}); });
describe("ResponseTableColumns", () => {
afterEach(() => {
cleanup();
});
test("includes verifiedEmailColumn when isVerifyEmailEnabled is true", () => {
// Arrange
const mockSurvey = {
questions: [],
variables: [],
hiddenFields: { fieldIds: [] },
isVerifyEmailEnabled: true,
} as unknown as TSurvey;
const mockT = vi.fn((key) => key);
const isExpanded = false;
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
// Assert
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
expect(verifiedEmailColumn).toBeDefined();
expect(verifiedEmailColumn?.accessorKey).toBe("verifiedEmail");
// Call the header function to trigger the t function call with "common.verified_email"
if (verifiedEmailColumn && typeof verifiedEmailColumn.header === "function") {
verifiedEmailColumn.header();
expect(mockT).toHaveBeenCalledWith("common.verified_email");
}
});
test("excludes verifiedEmailColumn when isVerifyEmailEnabled is false", () => {
// Arrange
const mockSurvey = {
questions: [],
variables: [],
hiddenFields: { fieldIds: [] },
isVerifyEmailEnabled: false,
} as unknown as TSurvey;
const mockT = vi.fn((key) => key);
const isExpanded = false;
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
// Assert
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
expect(verifiedEmailColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Column Implementations", () => {
afterEach(() => {
cleanup();
});
test("dateColumn renders with formatted date", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
expect(dateColumn).toBeDefined();
// Call the header function to test it returns the expected value
expect(dateColumn?.header?.()).toBe("common.date");
// Mock a response with a date to test the cell function
const mockRow = {
original: { createdAt: "2023-01-01T12:00:00Z" },
} as any;
// Call the cell function and check the formatted date
dateColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(getFormattedDateTimeString)).toHaveBeenCalledWith(new Date("2023-01-01T12:00:00Z"));
});
test("personColumn renders anonymous when person is null", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
// Test header content
const headerResult = personColumn?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with no person
const mockRow = {
original: { person: null },
} as any;
// Mock the t function for this specific call
t.mockReturnValueOnce("Anonymous User");
// Call the cell function and check it returns "Anonymous"
const cellResult = personColumn?.cell?.({ row: mockRow } as any);
expect(t).toHaveBeenCalledWith("common.anonymous");
expect(cellResult?.props?.children).toBe("Anonymous User");
});
test("personColumn renders person identifier when person exists", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
// Mock a response with a person
const mockRow = {
original: {
person: { id: "123", attributes: { email: "test@example.com" } },
contactAttributes: { name: "John Doe" },
},
} as any;
// Call the cell function
personColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(getContactIdentifier)).toHaveBeenCalledWith(
mockRow.original.person,
mockRow.original.contactAttributes
);
});
test("tagsColumn returns undefined when tags is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
expect(tagsColumn).toBeDefined();
// Mock a response with no tags
const mockRow = {
original: { tags: null },
} as any;
// Call the cell function
const cellResult = tagsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("notesColumn renders when notes is an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with notes
const mockRow = {
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
} as any;
// Call the cell function
notesColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
});
test("notesColumn returns undefined when notes is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with no notes
const mockRow = {
original: { notes: null },
} as any;
// Call the cell function
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find the variable column for var1
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
expect(var1Column).toBeDefined();
// Test the header
const headerResult = var1Column?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with a string variable
const mockRow = {
original: { variables: { var1: "Test Value" } },
} as any;
// Call the cell function
const cellResult = var1Column?.cell?.({ row: mockRow } as any);
expect(cellResult?.props.children).toBe("Test Value");
// Test with a number variable
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
expect(var2Column).toBeDefined();
const mockRowNumber = {
original: { variables: { var2: 42 } },
} as any;
const cellResultNumber = var2Column?.cell?.({ row: mockRowNumber } as any);
expect(cellResultNumber?.props.children).toBe(42);
});
test("hiddenFieldColumns render when fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find the hidden field column
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hfColumn).toBeDefined();
// Test the header
const headerResult = hfColumn?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with a hidden field value
const mockRow = {
original: { responseData: { hf1: "Hidden Value" } },
} as any;
// Call the cell function
const cellResult = hfColumn?.cell?.({ row: mockRow } as any);
expect(cellResult?.props.children).toBe("Hidden Value");
});
test("hiddenFieldColumns are empty when fieldIds don't exist", () => {
// Create a survey with no hidden field IDs
const surveyWithNoHiddenFields = {
...mockSurvey,
hiddenFields: { enabled: true }, // no fieldIds
};
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
// Check that no hidden field columns were created
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hfColumn).toBeUndefined();
});
});

View File

@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => { {summaryItems.map((summaryItem) => {
return ( return (
<button <button

View File

@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
</div> </div>
)} )}
</div> </div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> <div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
{renderResponseValue(response.value)} {renderResponseValue(response.value)}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 text-slate-500 md:px-6">

View File

@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
return ( return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}> <div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer"> <a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2"> <div className="absolute top-0 right-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" /> <DownloadIcon className="h-6 text-slate-500" />
</div> </div>

View File

@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6"> <div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}> <div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
</div> </div>
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
</div> </div>
)} )}
</div> </div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> <div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
{response.value} {response.value}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 text-slate-500 md:px-6">

View File

@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<table className="mx-auto border-collapse cursor-default text-left"> <table className="mx-auto border-collapse cursor-default text-left">
<thead> <thead>
<tr> <tr>
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th> <th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => ( {columns.map((column) => (
<th key={column} className="text-center font-medium"> <th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}> <TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody> <tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => ( {questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}> <tr key={rowLabel}>
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4"> <td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}> <TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p> <p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer> </TooltipRenderer>

View File

@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
) : undefined ) : undefined
} }
/> />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => ( {results.map((result, resultsIdx) => (
<Fragment key={result.value}> <Fragment key={result.value}>
<button <button

View File

@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( {["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"
@@ -72,7 +72,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}> className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1"> <div className="mr-8 flex space-x-1">
<p <p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}> className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group} {group}
</p> </p>
<div> <div>
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
))} ))}
</div> </div>
<div className="flex justify-center pb-4 pt-4"> <div className="flex justify-center pt-4 pb-4">
<HalfCircle value={questionSummary.score} /> <HalfCircle value={questionSummary.score} />
</div> </div>
</div> </div>

View File

@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined ) : undefined
} }
/> />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, index) => ( {results.map((result, index) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"

View File

@@ -26,7 +26,7 @@ export const QuestionSummaryHeader = ({
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type); const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
return ( return (
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6"> <div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}> <div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl"> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes( {formatTextWithSlashes(

View File

@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div> </div>
} }
/> />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => ( {questionSummary.choices.map((result) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"

View File

@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
)} )}
</p> </p>
</div> </div>
<div className="whitespace-pre-wrap text-center font-semibold"> <div className="text-center font-semibold whitespace-pre-wrap">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"} {quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div> </div>
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div> <div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div>
<div className="pl-6 text-center md:px-6"> <div className="pl-6 text-center md:px-6">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span> <span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span> <span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>

View File

@@ -11,11 +11,7 @@ vi.mock("lucide-react", () => ({
vi.mock("@/modules/ui/components/tooltip", () => ({ vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }) => <>{children}</>, TooltipProvider: ({ children }) => <>{children}</>,
Tooltip: ({ children }) => <>{children}</>, Tooltip: ({ children }) => <>{children}</>,
TooltipTrigger: ({ children, onClick }) => ( TooltipTrigger: ({ children }) => <>{children}</>,
<button tabIndex={0} onClick={onClick} style={{ display: "inline-block" }}>
{children}
</button>
),
TooltipContent: ({ children }) => <>{children}</>, TooltipContent: ({ children }) => <>{children}</>,
})); }));
@@ -71,10 +67,8 @@ describe("SummaryMetadata", () => {
expect(screen.getByText("25%")).toBeInTheDocument(); expect(screen.getByText("25%")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument(); expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("1m 5.00s")).toBeInTheDocument(); expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
const btn = screen const btn = screen.getByRole("button");
.getAllByRole("button") expect(screen.queryByTestId("down")).toBeInTheDocument();
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
if (!btn) throw new Error("DropOffs toggle button not found");
await userEvent.click(btn); await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument(); expect(screen.queryByTestId("up")).toBeInTheDocument();
}); });
@@ -107,10 +101,8 @@ describe("SummaryMetadata", () => {
}; };
render(<Wrapper />); render(<Wrapper />);
expect(screen.getAllByText("-")).toHaveLength(1); expect(screen.getAllByText("-")).toHaveLength(1);
const btn = screen const btn = screen.getByRole("button");
.getAllByRole("button") expect(screen.queryByTestId("down")).toBeInTheDocument();
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
if (!btn) throw new Error("DropOffs toggle button not found");
await userEvent.click(btn); await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument(); expect(screen.queryByTestId("up")).toBeInTheDocument();
}); });

View File

@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}> <TooltipProvider delayDuration={50}>
<Tooltip> <Tooltip>
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle"> <TooltipTrigger>
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm"> <div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<span className="text-sm text-slate-600"> <span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")} {t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && ( {`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -117,13 +117,15 @@ export const SummaryMetadata = ({
)} )}
</span> </span>
{!isLoading && ( {!isLoading && (
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"> <button
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
onClick={() => setShowDropOffs(!showDropOffs)}>
{showDropOffs ? ( {showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" /> <ChevronUpIcon className="h-4 w-4" />
) : ( ) : (
<ChevronDownIcon className="h-4 w-4" /> <ChevronDownIcon className="h-4 w-4" />
)} )}
</span> </button>
)} )}
</div> </div>
</div> </div>

View File

@@ -30,7 +30,6 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host", SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
})); }));
// Create a spy for refreshSingleUseId so we can override it in tests // Create a spy for refreshSingleUseId so we can override it in tests

View File

@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
} catch (error) { } catch (error) {
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code")); toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
} }
}, [surveyUrl, t]); }, [surveyUrl]);
const downloadQRCode = () => { const downloadQRCode = () => {
try { try {

View File

@@ -96,7 +96,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
throw new ResourceNotFoundError("Organization not found", organizationId); throw new ResourceNotFoundError("Organization not found", organizationId);
} }
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization.billing.plan);
if (!isSurveyFollowUpsEnabled) { if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization"); throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
} }

View File

@@ -250,7 +250,6 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
if (responsesDownloadUrlResponse?.data) { if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data; link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
@@ -391,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
value && handleDatePickerClose(); value && handleDatePickerClose();
}}> }}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none"> <DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3"> <div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex"> <div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span> <span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" /> <ArrowDownToLineIcon className="ml-2 h-4 w-4" />

View File

@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
key={`${o}-${index}`} key={`${o}-${index}`}
type="button" type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))} onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600"> className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
{o} {o}
<X width={14} height={14} className="ml-2" /> <X width={14} height={14} className="ml-2" />
</button> </button>
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled} disabled={disabled}
className={clsx( className={clsx(
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0", "h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent",
!disabled ? "cursor-pointer" : "opacity-50" !disabled ? "cursor-pointer" : "opacity-50"
)}> )}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -1,14 +1,7 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
import {
OptionsType,
QuestionOption,
QuestionOptions,
QuestionsComboBox,
SelectedCommandItem,
} from "./QuestionsComboBox";
describe("QuestionsComboBox", () => { describe("QuestionsComboBox", () => {
afterEach(() => { afterEach(() => {
@@ -60,67 +53,3 @@ describe("QuestionsComboBox", () => {
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
}); });
}); });
describe("SelectedCommandItem", () => {
test("renders question icon and color for QUESTIONS with questionType", () => {
const { container } = render(
<SelectedCommandItem
label="Q1"
type={OptionsType.QUESTIONS}
questionType={TSurveyQuestionTypeEnum.OpenText}
/>
);
expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Q1");
});
test("renders attribute icon and color for ATTRIBUTES", () => {
const { container } = render(<SelectedCommandItem label="Attr" type={OptionsType.ATTRIBUTES} />);
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Attr");
});
test("renders hidden field icon and color for HIDDEN_FIELDS", () => {
const { container } = render(<SelectedCommandItem label="Hidden" type={OptionsType.HIDDEN_FIELDS} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Hidden");
});
test("renders meta icon and color for META with label", () => {
const { container } = render(<SelectedCommandItem label="device" type={OptionsType.META} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("device");
});
test("renders other icon and color for OTHERS with label", () => {
const { container } = render(<SelectedCommandItem label="Language" type={OptionsType.OTHERS} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Language");
});
test("renders tag icon and color for TAGS", () => {
const { container } = render(<SelectedCommandItem label="Tag1" type={OptionsType.TAGS} />);
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Tag1");
});
test("renders fallback color and no icon for unknown type", () => {
const { container } = render(<SelectedCommandItem label="Unknown" type={"UNKNOWN"} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).not.toBeInTheDocument();
expect(container.textContent).toContain("Unknown");
});
test("renders fallback for non-string label", () => {
const { container } = render(
<SelectedCommandItem label={{ default: "NonString" }} type={OptionsType.QUESTIONS} />
);
expect(container.textContent).toContain("NonString");
});
});

View File

@@ -18,12 +18,11 @@ import {
CheckIcon, CheckIcon,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
ContactIcon,
EyeOff, EyeOff,
GlobeIcon, GlobeIcon,
GridIcon, GridIcon,
HashIcon, HashIcon,
HomeIcon, HelpCircleIcon,
ImageIcon, ImageIcon,
LanguagesIcon, LanguagesIcon,
ListIcon, ListIcon,
@@ -64,60 +63,59 @@ interface QuestionComboBoxProps {
onChangeValue: (option: QuestionOption) => void; onChangeValue: (option: QuestionOption) => void;
} }
const questionIcons = { const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
// questions
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
// attributes
[OptionsType.ATTRIBUTES]: User,
// hidden fields
[OptionsType.HIDDEN_FIELDS]: EyeOff,
// meta
device: SmartphoneIcon,
os: AirplayIcon,
browser: GlobeIcon,
source: GlobeIcon,
action: MousePointerClickIcon,
// others
Language: LanguagesIcon,
// tags
[OptionsType.TAGS]: HashIcon,
};
const getIcon = (type: string) => {
const IconComponent = questionIcons[type];
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
};
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => { const getIconType = () => {
if (type) { switch (type) {
if (type === OptionsType.QUESTIONS && questionType) { case OptionsType.QUESTIONS:
return getIcon(questionType); switch (questionType) {
} else if (type === OptionsType.ATTRIBUTES) { case TSurveyQuestionTypeEnum.OpenText:
return getIcon(OptionsType.ATTRIBUTES); return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
} else if (type === OptionsType.HIDDEN_FIELDS) { case TSurveyQuestionTypeEnum.Rating:
return getIcon(OptionsType.HIDDEN_FIELDS); return <StarIcon width={18} height={18} className="text-white" />;
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) { case TSurveyQuestionTypeEnum.CTA:
return getIcon(label); return <MousePointerClickIcon width={18} height={18} className="text-white" />;
} else if (type === OptionsType.TAGS) { case TSurveyQuestionTypeEnum.OpenText:
return getIcon(OptionsType.TAGS); return <HelpCircleIcon width={18} height={18} className="text-white" />;
} case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
return <ListIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
return <Rows3Icon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.NPS:
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Consent:
return <CheckIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.PictureSelection:
return <ImageIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Matrix:
return <GridIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Ranking:
return <ListOrderedIcon width={18} height={18} className="text-white" />;
}
case OptionsType.ATTRIBUTES:
return <User width={18} height={18} className="text-white" />;
case OptionsType.HIDDEN_FIELDS:
return <EyeOff width={18} height={18} className="text-white" />;
case OptionsType.META:
switch (label) {
case "device":
return <SmartphoneIcon width={18} height={18} className="text-white" />;
case "os":
return <AirplayIcon width={18} height={18} className="text-white" />;
case "browser":
return <GlobeIcon width={18} height={18} className="text-white" />;
case "source":
return <GlobeIcon width={18} height={18} className="text-white" />;
case "action":
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
}
case OptionsType.OTHERS:
switch (label) {
case "Language":
return <LanguagesIcon width={18} height={18} className="text-white" />;
}
case OptionsType.TAGS:
return <HashIcon width={18} height={18} className="text-white" />;
} }
}; };
@@ -166,7 +164,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
value={inputValue} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={t("common.search") + "..."} placeholder={t("common.search") + "..."}
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent" className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
/> />
)} )}
<div> <div>

View File

@@ -1,263 +0,0 @@
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useParams } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ResponseFilter } from "./ResponseFilter";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getSurveyFilterDataAction: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
generateQuestionAndFilterOptions: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useParams: vi.fn(),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [[vi.fn()]],
}));
vi.mock("./QuestionsComboBox", () => ({
QuestionsComboBox: ({ onChangeValue }) => (
<div data-testid="questions-combo-box">
<button onClick={() => onChangeValue({ id: "q1", label: "Question 1", type: "OpenText" })}>
Select Question
</button>
</div>
),
OptionsType: {
QUESTIONS: "Questions",
ATTRIBUTES: "Attributes",
TAGS: "Tags",
LANGUAGES: "Languages",
},
}));
// Update the mock for QuestionFilterComboBox to always render
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox",
() => ({
QuestionFilterComboBox: () => (
<div data-testid="filter-combo-box">
<button data-testid="select-filter-btn">Select Filter</button>
<button data-testid="select-filter-type-btn">Select Filter Type</button>
</div>
),
})
);
describe("ResponseFilter", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockSelectedFilter = {
filter: [],
onlyComplete: false,
};
const mockSelectedOptions = {
questionFilterOptions: [
{
type: TSurveyQuestionTypeEnum.OpenText,
filterOptions: ["equals", "does not equal"],
filterComboBoxOptions: [],
id: "q1",
},
],
questionOptions: [
{
label: "Questions",
type: "Questions",
option: [
{ id: "q1", label: "Question 1", type: "OpenText", questionType: TSurveyQuestionTypeEnum.OpenText },
],
},
],
} as any;
const mockSetSelectedFilter = vi.fn();
const mockSetSelectedOptions = vi.fn();
const mockSurvey = {
id: "survey1",
environmentId: "env1",
name: "Test Survey",
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
createdBy: "user1",
questions: [],
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
triggers: [],
displayOption: "displayOnce",
} as unknown as TSurvey;
beforeEach(() => {
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: mockSelectedFilter,
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
vi.mocked(useParams).mockReturnValue({ environmentId: "env1", surveyId: "survey1" });
vi.mocked(getSurveyFilterDataAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
vi.mocked(generateQuestionAndFilterOptions).mockReturnValue({
questionFilterOptions: mockSelectedOptions.questionFilterOptions,
questionOptions: mockSelectedOptions.questionOptions,
});
});
test("renders with default state", () => {
render(<ResponseFilter survey={mockSurvey} />);
expect(screen.getByText("Filter")).toBeInTheDocument();
});
test("opens the filter popover when clicked", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
});
test("fetches filter data when opened", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataAction).toHaveBeenCalledWith({ surveyId: "survey1" });
expect(mockSetSelectedOptions).toHaveBeenCalled();
});
test("handles adding new filter", async () => {
// Start with an empty filter
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [], onlyComplete: false },
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Verify there's no filter yet
expect(screen.queryByTestId("questions-combo-box")).not.toBeInTheDocument();
// Add a new filter and check that the questions combo box appears
await userEvent.click(screen.getByText("common.add_filter"));
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
});
test("handles only complete checkbox toggle", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByRole("checkbox"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
});
test("handles selecting question and filter options", async () => {
// Setup with a pre-populated filter to ensure the filter components are rendered
const setSelectedFilterMock = vi.fn();
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [
{
questionType: { id: "q1", label: "Question 1", type: "OpenText" },
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
onlyComplete: false,
},
setSelectedFilter: setSelectedFilterMock,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Verify both combo boxes are rendered
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
expect(screen.getByTestId("filter-combo-box")).toBeInTheDocument();
// Use data-testid to find our buttons instead of text
await userEvent.click(screen.getByText("Select Question"));
await userEvent.click(screen.getByTestId("select-filter-btn"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(setSelectedFilterMock).toHaveBeenCalled();
});
test("handles clear all filters", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByText("common.clear_all"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
});
test("uses sharing key action when on sharing page", async () => {
vi.mocked(useParams).mockReturnValue({
environmentId: "env1",
surveyId: "survey1",
sharingKey: "share123",
});
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
sharingKey: "share123",
environmentId: "env1",
});
});
});

View File

@@ -38,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
POSTHOG_API_KEY: "test-posthog-api-key", POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id", FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true, IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
})); }));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({

View File

@@ -1,35 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import ForgotPasswordPage from "./page";
vi.mock("@/modules/auth/forgot-password/page", () => ({
ForgotPasswordPage: () => (
<div data-testid="forgot-password-page">
<div data-testid="form-wrapper">
<div data-testid="forgot-password-form">Forgot Password Form</div>
</div>
</div>
),
}));
describe("ForgotPasswordPage", () => {
afterEach(() => {
cleanup();
});
test("renders the forgot password page", () => {
render(<ForgotPasswordPage />);
expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument();
});
test("renders the form wrapper", () => {
render(<ForgotPasswordPage />);
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
});
test("renders the forgot password form", () => {
render(<ForgotPasswordPage />);
expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument();
});
});

View File

@@ -1,20 +0,0 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import EmailChangeWithoutVerificationSuccessPage from "./page";
vi.mock("@/modules/auth/email-change-without-verification-success/page", () => ({
EmailChangeWithoutVerificationSuccessPage: ({ children }) => (
<div data-testid="email-change-success-page">{children}</div>
),
}));
describe("EmailChangeWithoutVerificationSuccessPage", () => {
afterEach(() => {
cleanup();
});
test("renders EmailChangeWithoutVerificationSuccessPage", () => {
const { getByTestId } = render(<EmailChangeWithoutVerificationSuccessPage />);
expect(getByTestId("email-change-success-page")).toBeInTheDocument();
});
});

View File

@@ -1,3 +0,0 @@
import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page";
export default EmailChangeWithoutVerificationSuccessPage;

View File

@@ -1,3 +0,0 @@
import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page";
export default VerifyEmailChangePage;

View File

@@ -1,74 +0,0 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { useRouter } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import ClientEnvironmentRedirect from "./ClientEnvironmentRedirect";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("ClientEnvironmentRedirect", () => {
afterEach(() => {
cleanup();
});
test("should redirect to the provided environment ID when no last environment exists", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
});
test("should redirect to the last environment ID when it exists in localStorage", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage with a last environment ID
const localStorageMock = {
getItem: vi.fn().mockReturnValue("last-env-id"),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
});
test("should update redirect when environment ID prop changes", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
// Clear mock calls
mockPush.mockClear();
// Rerender with new environment ID
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
});
});

View File

@@ -0,0 +1,235 @@
import {
mockContactEmailFollowUp,
mockDirectEmailFollowUp,
mockEndingFollowUp,
mockEndingId2,
mockResponse,
mockResponseEmailFollowUp,
mockResponseWithContactQuestion,
mockSurvey,
mockSurveyWithContactQuestion,
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
import { sendFollowUpEmail } from "@/modules/email";
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
// Mock dependencies
vi.mock("@/modules/email", () => ({
sendFollowUpEmail: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Survey Follow Up", () => {
const mockOrganization: Partial<TOrganization> = {
id: "org1",
name: "Test Org",
whitelabel: {
logoUrl: "https://example.com/logo.png",
},
};
describe("evaluateFollowUp", () => {
test("sends email when to is a direct email address", async () => {
const followUpId = mockDirectEmailFollowUp.id;
const followUpAction = mockDirectEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
mockResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockDirectEmailFollowUp.action.properties.body,
subject: mockDirectEmailFollowUp.action.properties.subject,
to: mockDirectEmailFollowUp.action.properties.to,
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockResponseEmailFollowUp.action.properties.body,
subject: mockResponseEmailFollowUp.action.properties.subject,
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email in array", async () => {
const followUpId = mockContactEmailFollowUp.id;
const followUpAction = mockContactEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurveyWithContactQuestion,
mockResponseWithContactQuestion,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockContactEmailFollowUp.action.properties.body,
subject: mockContactEmailFollowUp.action.properties.subject,
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
survey: mockSurveyWithContactQuestion,
response: mockResponseWithContactQuestion,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("throws error when to value is not found in response data", async () => {
const followUpId = "followup1";
const followUpAction = {
...mockSurvey.followUps![0].action,
properties: {
...mockSurvey.followUps![0].action.properties,
to: "nonExistentField",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
});
test("throws error when email address is invalid", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
const invalidResponse = {
...mockResponse,
data: {
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
invalidResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
});
});
describe("sendSurveyFollowUps", () => {
test("skips follow-up when ending Id doesn't match", async () => {
const responseWithDifferentEnding = {
...mockResponse,
endingId: mockEndingId2,
};
const mockSurveyWithEndingFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockEndingFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithEndingFollowUp,
responseWithDifferentEnding as TResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockEndingFollowUp.id,
status: "skipped",
},
]);
expect(sendFollowUpEmail).not.toHaveBeenCalled();
});
test("processes follow-ups and log errors", async () => {
const error = new Error("Test error");
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
const mockSurveyWithFollowUps: TSurvey = {
...mockSurvey,
followUps: [mockResponseEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUps,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockResponseEmailFollowUp.id,
status: "error",
error: "Test error",
},
]);
expect(logger.error).toHaveBeenCalledWith(
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
"Follow-up processing errors"
);
});
test("successfully processes follow-ups", async () => {
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
const mockSurveyWithFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockDirectEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUp,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockDirectEmailFollowUp.id,
status: "success",
},
]);
expect(logger.error).not.toHaveBeenCalled();
});
});
});

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