Compare commits

...

59 Commits

Author SHA1 Message Date
harshsbhat
8341a93270 rabbit 2025-05-21 08:41:59 +05:30
harshsbhat
fb8c22af96 feat: update pricing 2025-05-20 23:46:12 +05:30
Johannes
9478946c7a fix: fix icon in new docs page (#5836) 2025-05-19 04:53:57 -07:00
Johannes
8560bbf28b docs: documentation of multi-tenancy of Formbricks Cloud (#5835) 2025-05-19 04:47:26 -07:00
victorvhs017
df7afe1b64 fix: non-interactive elements without roles (#5804)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-19 10:10:13 +00:00
Piyush Gupta
df52b60d61 fix: env-var-generation in mac os for self-hosting (#5814) 2025-05-17 07:50:15 +00:00
Jakob Schott
65b051f0eb feat: download selection of responses (#5488)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-17 00:59:14 +00:00
Dhruwang Jariwala
7678084061 fix: unknown property warnings (#5800) 2025-05-16 13:45:48 +00:00
victorvhs017
022d33d06f chore: track server action with sentry and general fixes (#5799) 2025-05-16 12:02:06 +00:00
Anshuman Pandey
4d157bf8dc fix: user attributes updates api email fix (#5827) 2025-05-16 11:48:34 +00:00
Dhruwang Jariwala
9fcbe4e8c5 chore: swap next and back button input (#5748)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-16 08:51:12 +00:00
Piyush Gupta
5aeb92eb4f chore: removes https enforcement from management api (#5810) 2025-05-15 19:40:04 +00:00
Matti Nannt
00dfa629b5 fix: build process warnings (#5734)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-15 15:46:05 +00:00
Piyush Gupta
3ca471b6a2 feat: implement user management role configuration and access control (#5808) 2025-05-15 15:09:33 +00:00
Dhruwang Jariwala
a525589186 fix: token permisson issues (#4986) 2025-05-15 08:29:40 +00:00
Piyush Gupta
59ed10398d fix: suid bugs (#5780)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-14 12:09:41 +00:00
Piyush Gupta
25a86e31df fix: promise misuse error (#5783) 2025-05-14 12:06:41 +00:00
Matti Nannt
7d6743a81a chore(deps): upgrade npm dependencies in /packages (#5807) 2025-05-14 14:09:26 +02:00
Matti Nannt
6616f62da5 chore: remove unused prisma accelerate extension (#5682) 2025-05-14 12:34:08 +02:00
dependabot[bot]
a3cbc05e12 chore(deps): bump marocchino/sticky-pull-request-comment from 2.9.1 to 2.9.2 (#5760)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:29:43 +02:00
dependabot[bot]
97095a627a chore(deps): bump aws-actions/configure-aws-credentials from 4.0.2 to 4.2.0 (#5757)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:59 +02:00
dependabot[bot]
910d257c56 chore(deps): bump SonarSource/sonarqube-scan-action from 5.1.0 to 5.2.0 (#5758)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:39 +02:00
dependabot[bot]
0c0a008b28 chore(deps): bump actions/dependency-review-action from 4.6.0 to 4.7.0 (#5759)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:27:14 +02:00
dependabot[bot]
9879458353 chore(deps): bump borchero/terraform-plan-comment from 2.4.0 to 2.4.1 (#5761)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 12:26:46 +02:00
Matti Nannt
d44f1f3b4b chore: remove dependabot action and return to simple repository setup (#5806) 2025-05-14 12:23:38 +02:00
Dhruwang Jariwala
c5d387a7e5 fix: multi choice issues (#5802) 2025-05-14 09:59:22 +00:00
Anshuman Pandey
a6aacd5c55 fix: transaction timeout and max contacts (#5415)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-14 11:01:39 +02:00
Piyush Gupta
57e7485564 fix: reliability issues (#5781) 2025-05-13 16:40:16 +00:00
Matti Nannt
42a38a6f47 chore: fix environment surveys filter not working properly (#5793) 2025-05-13 13:34:27 +02:00
Dhruwang Jariwala
34bb9c2127 fix: multi select question (#5792)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-13 13:12:22 +02:00
Dhruwang Jariwala
6442b5e4aa fix: Vietnamese char interpretation (#5747) 2025-05-13 11:54:30 +02:00
Piyush Gupta
dde5a55446 fix: CTA and consent question breaking the survey editor (#5745) 2025-05-13 04:18:34 +00:00
Piyush Gupta
13e615a798 fix: duplicate switch cases (#5752)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-12 15:00:55 +00:00
victorvhs017
9c81961b0b chore: remove redundant code (#5751) 2025-05-12 12:23:05 +00:00
Matti Nannt
c1a35e2d75 chore: introduce new reliable cache for enterprise license check (#5740)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-12 10:41:53 +02:00
Piyush Gupta
13415c75c2 docs: adds auth-behaviour docs (#5743) 2025-05-12 05:28:12 +00:00
Johannes
300557a0e6 docs: update ee feature table (#5744) 2025-05-11 22:10:40 -07:00
Anshuman Pandey
fcbb97010c fix: follow ups ending card (#5732) 2025-05-10 10:30:49 +02:00
Matti Nannt
6be46b16b2 fix: limit number of surveys in environment state (#5715) 2025-05-09 16:10:01 +00:00
Matti Nannt
35b2356a31 fix: nextjs cache handler for next 15 (#5717)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-09 17:51:57 +02:00
Johannes
53ef756723 test: last round (#5731) 2025-05-09 10:09:09 +02:00
Johannes
0f0b743a10 test: backfill variety of test files (#5729)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-09 07:26:41 +00:00
Matti Nannt
3f7dafb65c fix: failing authOptions test because of missing mock (#5727) 2025-05-09 02:28:55 +02:00
Matti Nannt
9df791b5ff chore: add tests for apps/web/lib files (#5725) (#5726) 2025-05-09 01:35:50 +02:00
Dhruwang Jariwala
dea40d9757 test: unit test for survey.ts (#5724) 2025-05-09 00:54:19 +02:00
Matti Nannt
dd12a589d6 chore: add tests for apps/web/lib files (#5725) 2025-05-08 20:54:50 +00:00
Harsh Bhat
af6e5ba31e chore: add tests for packages/surveys/lib (#5722)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-08 20:31:52 +00:00
victorvhs017
2b57b2080b chore: fixed included files on surveys package tests (#5723) 2025-05-08 19:49:02 +00:00
Johannes
154c85a0f7 test: add test to multiple files (#5719)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-08 19:24:50 +00:00
Piyush Gupta
3f465d4594 chore: adds response utils tests (#5721) 2025-05-08 18:44:09 +00:00
Anshuman Pandey
94e883f4c3 chore: adds tests for apps/web/modules/survey/editor/lib (#5720) 2025-05-08 18:42:40 +00:00
victorvhs017
38622101f1 chore: add tests to apps/web/modules/survey/components and apps/web/modules/survey/link (#5707) 2025-05-08 17:24:46 +00:00
Dhruwang Jariwala
0eb64c0084 test: ui module test part 2 (#5716) 2025-05-08 15:37:59 +00:00
Johannes
409f5b1791 test: onboarding unit tests (#5712)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-08 14:57:20 +00:00
Harsh Bhat
14398a9c4f chore: add tests for package/surveys/src/components/general (#5699) 2025-05-08 13:23:36 +00:00
Piyush Gupta
d1cdf6e216 chore: adds unit tests for ee/contact/lib and modules/organization (#5713) 2025-05-08 13:06:39 +00:00
Dhruwang Jariwala
65da25a626 fix: rate limiting on login page (#5710) 2025-05-08 12:29:58 +00:00
Anshuman Pandey
ce8b019e93 chore: modules/survey/lib and modules/ee/contacts/api/v1 (#5711) 2025-05-08 10:52:55 +00:00
Dhruwang Jariwala
67d7fe016d test: Test for UI module (Part 1) (#5703) 2025-05-08 08:16:24 +00:00
658 changed files with 55533 additions and 6169 deletions

View File

@@ -0,0 +1,6 @@
---
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,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
# 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)
# (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=
@@ -191,8 +190,7 @@ UNSPLASH_ACCESS_KEY=
# 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
REDIS_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# REDIS_URL=redis://localhost:6379
# 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:
@@ -200,9 +198,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
@@ -216,5 +211,5 @@ UNKEY_ROOT_KEY=
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT=1
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ jobs:
revert
ossgg
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
# When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message.
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
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
message: |

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
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}
/>
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -0,0 +1,144 @@
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",
}));
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);
if (!isAuthorized) {
throw AuthorizationError;
throw new AuthorizationError("User is not authorized to access this environment");
}
return <div className="flex-1 bg-slate-50">{children}</div>;

View File

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,80 @@
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

@@ -0,0 +1,60 @@
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} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,75 @@
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

@@ -0,0 +1,184 @@
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",
}));
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

@@ -0,0 +1,197 @@
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",
}));
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 { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";

View File

@@ -0,0 +1,88 @@
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} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -0,0 +1,220 @@
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,
}));
// 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

@@ -0,0 +1,72 @@
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

@@ -0,0 +1,124 @@
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"
width={256}
height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute left-2 top-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>

View File

@@ -0,0 +1,106 @@
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 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -0,0 +1,106 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import EnterpriseSettingsPage from "./page";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
});
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 } });
render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
expect(redirect).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
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">
<svg
viewBox="0 0 1024 1024"
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"
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"
aria-hidden="true">
<circle
cx={512}

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
<button
type="button"
aria-label="Expand response"
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"
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"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
</button>

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute top-0 right-0 m-2">
<div className="absolute right-0 top-0 m-2">
<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" />
</div>

View File

@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<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>
</div>
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
</div>
)}
</div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<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">
<thead>
<tr>
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => (
<th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}>
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer>

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<button
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);
return (
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes(

View File

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

View File

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

View File

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

View File

@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<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">
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
<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">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -117,15 +117,13 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<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)}>
<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">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</button>
</span>
)}
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
<DropdownMenuTrigger
disabled={disabled}
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:ring-0 focus:outline-transparent",
"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",
!disabled ? "cursor-pointer" : "opacity-50"
)}>
<div className="flex items-center justify-between">

View File

@@ -1,7 +1,14 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
OptionsType,
QuestionOption,
QuestionOptions,
QuestionsComboBox,
SelectedCommandItem,
} from "./QuestionsComboBox";
describe("QuestionsComboBox", () => {
afterEach(() => {
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
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,11 +18,12 @@ import {
CheckIcon,
ChevronDown,
ChevronUp,
ContactIcon,
EyeOff,
GlobeIcon,
GridIcon,
HashIcon,
HelpCircleIcon,
HomeIcon,
ImageIcon,
LanguagesIcon,
ListIcon,
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
onChangeValue: (option: QuestionOption) => void;
}
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => {
switch (type) {
case OptionsType.QUESTIONS:
switch (questionType) {
case TSurveyQuestionTypeEnum.OpenText:
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Rating:
return <StarIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.CTA:
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.OpenText:
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" />;
const questionIcons = {
// 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,
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" />;
// 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 = () => {
if (type) {
if (type === OptionsType.QUESTIONS && questionType) {
return getIcon(questionType);
} else if (type === OptionsType.ATTRIBUTES) {
return getIcon(OptionsType.ATTRIBUTES);
} else if (type === OptionsType.HIDDEN_FIELDS) {
return getIcon(OptionsType.HIDDEN_FIELDS);
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
return getIcon(label);
} else if (type === OptionsType.TAGS) {
return getIcon(OptionsType.TAGS);
}
}
};
@@ -164,7 +166,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
value={inputValue}
onValueChange={setInputValue}
placeholder={t("common.search") + "..."}
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"
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"
/>
)}
<div>

View File

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,74 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { useRouter } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import ClientEnvironmentRedirect from "./ClientEnvironmentRedirect";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("ClientEnvironmentRedirect", () => {
afterEach(() => {
cleanup();
});
test("should redirect to the provided environment ID when no last environment exists", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<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

@@ -1,235 +0,0 @@
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();
});
});
});

View File

@@ -1,135 +0,0 @@
import { sendFollowUpEmail } from "@/modules/email";
import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
type FollowUpResult = {
followUpId: string;
status: "success" | "error" | "skipped";
error?: string;
};
export const evaluateFollowUp = async (
followUpId: string,
followUpAction: TSurveyFollowUpAction,
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<void> => {
const { properties } = followUpAction;
const { to, subject, body, replyTo } = properties;
const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl || "";
// Check if 'to' is a direct email address (team member or user email)
const parsedEmailTo = z.string().email().safeParse(to);
if (parsedEmailTo.success) {
// 'to' is a valid email address, send email directly
await sendFollowUpEmail({
html: body,
subject,
to: parsedEmailTo.data,
replyTo,
survey,
response,
attachResponseData: properties.attachResponseData,
logoUrl,
});
return;
}
// If not a direct email, check if it's a question ID or hidden field ID
if (!toValueFromResponse) {
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
}
if (typeof toValueFromResponse === "string") {
// parse this string to check for an email:
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.data) {
// send email to this email address
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
} else if (Array.isArray(toValueFromResponse)) {
const emailAddress = toValueFromResponse[2];
if (!emailAddress) {
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
}
};
export const sendSurveyFollowUps = async (
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<FollowUpResult[]> => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
// Check if we should skip this follow-up based on ending IDs
if (trigger.properties) {
const { endingIds } = trigger.properties;
const { endingId } = response;
if (!endingId || !endingIds.includes(endingId)) {
return Promise.resolve({
followUpId: followUp.id,
status: "skipped",
});
}
}
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
.then(() => ({
followUpId: followUp.id,
status: "success" as const,
}))
.catch((error) => ({
followUpId: followUp.id,
status: "error" as const,
error: error instanceof Error ? error.message : "Something went wrong",
}));
});
const followUpResults = await Promise.all(followUpPromises);
// Log all errors
const errors = followUpResults
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) {
logger.error(errors, "Follow-up processing errors");
}
return followUpResults;
};

View File

@@ -1,4 +1,3 @@
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { sendResponseFinishedEmail } from "@/modules/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
select: { email: true, locale: true },
});
// send follow up emails
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
if (surveyFollowUpsPermission) {
await sendSurveyFollowUps(survey, response, organization);
if (survey.followUps?.length > 0) {
// send follow up emails
const followUpsResult = await sendFollowUpsForResponse(response.id);
if (!followUpsResult.ok) {
const { error: followUpsError } = followUpsResult;
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
}
}
}
const emailPromises = usersWithNotifications.map((user) =>

View File

@@ -254,7 +254,7 @@ describe("getEnvironmentState", () => {
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue([mockSurveys[0]]); // Only return the app, inProgress survey
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
});

View File

@@ -99,12 +99,8 @@ export const getEnvironmentState = async (
getActionClassesForEnvironmentState(environmentId),
]);
const filteredSurveys = surveys.filter(
(survey) => survey.type === "app" && survey.status === "inProgress"
);
const data: TJsEnvironmentState["data"] = {
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
surveys: !isMonthlyResponsesLimitReached ? surveys : [],
actionClasses,
project: project,
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),

View File

@@ -100,8 +100,14 @@ describe("getSurveysForEnvironmentState", () => {
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
where: {
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object), // Check if select is called, specific fields are in the original code
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
expect(result).toEqual([mockTransformedSurvey]);
@@ -114,8 +120,14 @@ describe("getSurveysForEnvironmentState", () => {
const result = await getSurveysForEnvironmentState(environmentId);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
where: {
environmentId,
type: "app",
status: "inProgress",
},
select: expect.any(Object),
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(result).toEqual([]);

View File

@@ -20,7 +20,13 @@ export const getSurveysForEnvironmentState = reactCache(
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
type: "app",
status: "inProgress",
},
orderBy: {
createdAt: "desc",
},
take: 30,
select: {
id: true,
welcomeCard: true,

View File

@@ -57,6 +57,10 @@ export const PUT = async (
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (response.finished) {
return responses.badRequestResponse("Response is already finished", undefined, true);
}
// get survey to get environmentId
let survey;
try {

View File

@@ -33,6 +33,7 @@ export const responseSelection = {
singleUseId: true,
language: true,
displayId: true,
endingId: true,
contact: {
select: {
id: true,

View File

@@ -7,39 +7,133 @@ export const GET = async (req: NextRequest) => {
return new ImageResponse(
(
<div tw={`flex flex-col w-full h-full items-center bg-[${brandColor}]/75 rounded-xl `}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
borderRadius: "0.75rem",
}}>
<div
tw="flex flex-col w-[80%] h-[60%] bg-white rounded-xl mt-13 absolute left-12 top-3 opacity-20"
style={{
display: "flex",
flexDirection: "column",
width: "80%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
tw="flex flex-col w-[84%] h-[60%] bg-white rounded-xl mt-12 absolute top-5 left-13 border-2 opacity-60"
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
tw="flex flex-col w-[85%] h-[67%] items-center bg-white rounded-xl mt-8 absolute top-[2.3rem] left-14"
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}>
<div tw="flex flex-col w-full">
<div tw="flex flex-col md:flex-row w-full md:items-center justify-between ">
<div tw="flex flex-col px-8">
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
justifyContent: "space-between",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
paddingLeft: "2rem",
paddingRight: "2rem",
}}>
<h2
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}>
{name}
</h2>
</div>
</div>
<div tw="flex justify-end mr-10 ">
<div tw="flex rounded-2xl absolute -right-2 mt-2">
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div
style={{
display: "flex",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div tw="flex rounded-2xl shadow ">
<a
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}>
Begin!
</a>
</div>
</div>
</div>
</div>

View File

@@ -1,15 +1,16 @@
import { webhookCache } from "@/lib/cache/webhook";
import { Webhook } from "@prisma/client";
import { Prisma, Webhook } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ValidationError } from "@formbricks/types/errors";
import { deleteWebhook } from "./webhook";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { deleteWebhook, getWebhook } from "./webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
delete: vi.fn(),
findUnique: vi.fn(),
},
},
}));
@@ -33,9 +34,15 @@ vi.mock("@/lib/utils/validate", () => ({
},
}));
vi.mock("@/lib/cache", () => ({
// Accept any function and return the exact same generic Fn keeps typings intact
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
}));
describe("deleteWebhook", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should delete the webhook and return the deleted webhook object when provided with a valid webhook ID", async () => {
@@ -105,4 +112,133 @@ describe("deleteWebhook", () => {
expect(prisma.webhook.delete).not.toHaveBeenCalled();
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError when webhook does not exist", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: "P2025",
clientVersion: "1.0.0",
});
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError);
await expect(deleteWebhook("non-existent-id")).rejects.toThrow(ResourceNotFoundError);
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
test("should throw DatabaseError when database operation fails", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError);
await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
test("should throw DatabaseError when an unknown error occurs", async () => {
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Unknown error"));
await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
});
describe("getWebhook", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should return webhook when it exists", async () => {
const mockedWebhook: Webhook = {
id: "test-webhook-id",
url: "https://example.com",
name: "Test Webhook",
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
};
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook);
const webhook = await getWebhook("test-webhook-id");
expect(webhook).toEqual(mockedWebhook);
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
where: {
id: "test-webhook-id",
},
});
});
test("should return null when webhook does not exist", async () => {
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null);
const webhook = await getWebhook("non-existent-id");
expect(webhook).toBeNull();
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
where: {
id: "non-existent-id",
},
});
});
test("should throw ValidationError when called with invalid webhook ID", async () => {
const { validateInputs } = await import("@/lib/utils/validate");
(validateInputs as any).mockImplementation(() => {
throw new ValidationError("Validation failed");
});
await expect(getWebhook("invalid-id")).rejects.toThrow(ValidationError);
expect(prisma.webhook.findUnique).not.toHaveBeenCalled();
});
test("should throw DatabaseError when database operation fails", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(prismaError);
await expect(getWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
});
test("should throw original error when an unknown error occurs", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(unknownError);
await expect(getWebhook("test-webhook-id")).rejects.toThrow(unknownError);
});
test("should use cache when getting webhook", async () => {
const mockedWebhook: Webhook = {
id: "test-webhook-id",
url: "https://example.com",
name: "Test Webhook",
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
};
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook);
const webhook = await getWebhook("test-webhook-id");
expect(webhook).toEqual(mockedWebhook);
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
where: {
id: "test-webhook-id",
},
});
});
});

View File

@@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
contactId,
surveyId,
displayId,
endingId,
finished,
data,
meta,
@@ -64,7 +65,8 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
finished,
endingId,
data: data,
language: language,
...(contact?.id && {

View File

@@ -3,6 +3,7 @@ import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/respon
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -40,6 +41,13 @@ vi.mock("@formbricks/logger", () => ({
},
}));
vi.mock("@/lib/crypto", () => ({
symmetricDecrypt: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
ENCRYPTION_KEY: "test-key",
}));
const mockSurvey: TSurvey = {
id: "survey-1",
createdAt: new Date(),
@@ -206,4 +214,119 @@ describe("checkSurveyValidity", () => {
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeNull();
});
test("should return badRequestResponse if singleUse is enabled and singleUseId is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: {},
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if meta.url is invalid", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url: "not-a-url" },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Invalid URL in response metadata",
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
);
});
test("should return badRequestResponse if suId is missing from url", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?foo=bar";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
expect(resultEncryptedMismatch?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-2";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(400);
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
surveyId: survey.id,
environmentId: "env-1",
});
});
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
const url = "https://example.com/?suId=su-1";
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(result).toBeNull();
});
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
const url = "https://example.com/?suId=encrypted-id";
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
singleUseId: "su-1",
meta: { url },
});
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
expect(_resultEncryptedMatch).toBeNull();
});
});

View File

@@ -2,6 +2,8 @@ import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[envi
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -24,6 +26,55 @@ export const checkSurveyValidity = async (
);
}
if (survey.singleUse?.enabled) {
if (!responseInput.singleUseId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (!responseInput.meta?.url) {
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
});
}
let url;
try {
url = new URL(responseInput.meta.url);
} catch (error) {
return responses.badRequestResponse("Invalid URL in response metadata", {
surveyId: survey.id,
environmentId,
error: error.message,
});
}
const suId = url.searchParams.get("suId");
if (!suId) {
return responses.badRequestResponse("Missing single use id", {
surveyId: survey.id,
environmentId,
});
}
if (survey.singleUse.isEncrypted) {
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
if (decryptedSuId !== responseInput.singleUseId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
} else if (responseInput.singleUseId !== suId) {
return responses.badRequestResponse("Invalid single use id", {
surveyId: survey.id,
environmentId,
});
}
}
if (survey.recaptcha?.enabled) {
if (!responseInput.recaptchaToken) {
logger.error("Missing recaptcha token");

View File

@@ -1,10 +1,11 @@
import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup } from "@testing-library/react";
import { TolgeeInstance } from "@tolgee/react";
import React from "react";
import { renderToString } from "react-dom/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import RootLayout from "./layout";
import RootLayout, { metadata } from "./layout";
// Mock dependencies for the layout
@@ -40,15 +41,6 @@ vi.mock("@/tolgee/server", () => ({
getTolgee: vi.fn(),
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
<div data-testid="ph-provider">
PHProvider: {posthogEnabled}
{children}
</div>
),
}));
vi.mock("@/tolgee/client", () => ({
TolgeeNextProvider: ({
children,
@@ -95,10 +87,53 @@ describe("RootLayout", () => {
const children = <div data-testid="child">Child Content</div>;
const element = await RootLayout({ children });
render(element);
const html = renderToString(element);
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
// Create a container and set its innerHTML
const container = document.createElement("div");
container.innerHTML = html;
document.body.appendChild(container);
// Now we can use screen queries on the rendered content
expect(container.querySelector('[data-testid="tolgee-next-provider"]')).toBeInTheDocument();
expect(container.querySelector('[data-testid="sentry-provider"]')).toBeInTheDocument();
expect(container.querySelector('[data-testid="child"]')).toHaveTextContent("Child Content");
// Cleanup
document.body.removeChild(container);
});
test("renders with different locale", async () => {
const fakeLocale = "de-DE";
vi.mocked(getLocale).mockResolvedValue(fakeLocale);
const fakeStaticData = { key: "value" };
const fakeTolgee = {
loadRequired: vi.fn().mockResolvedValue(fakeStaticData),
};
vi.mocked(getTolgee).mockResolvedValue(fakeTolgee as unknown as TolgeeInstance);
const children = <div data-testid="child">Child Content</div>;
const element = await RootLayout({ children });
const html = renderToString(element);
const container = document.createElement("div");
container.innerHTML = html;
document.body.appendChild(container);
const tolgeeProvider = container.querySelector('[data-testid="tolgee-next-provider"]');
expect(tolgeeProvider).toHaveTextContent(fakeLocale);
document.body.removeChild(container);
});
test("exports correct metadata", () => {
expect(metadata).toEqual({
title: {
template: "%s | Formbricks",
default: "Formbricks",
},
description: "Open-Source Survey Suite",
});
});
});

View File

@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { isValidCssSelector } from "./actionClass";
describe("isValidCssSelector", () => {
beforeEach(() => {
// Mock document.createElement and querySelector
const mockElement = {
querySelector: vi.fn(),
};
global.document = {
createElement: vi.fn(() => mockElement),
} as any;
});
test("should return false for undefined selector", () => {
expect(isValidCssSelector(undefined)).toBe(false);
});
test("should return false for empty string", () => {
expect(isValidCssSelector("")).toBe(false);
});
test("should return true for valid CSS selector", () => {
const mockElement = {
querySelector: vi.fn(),
};
(document.createElement as any).mockReturnValue(mockElement);
expect(isValidCssSelector(".class")).toBe(true);
expect(isValidCssSelector("#id")).toBe(true);
expect(isValidCssSelector("div")).toBe(true);
});
test("should return false for invalid CSS selector", () => {
const mockElement = {
querySelector: vi.fn(() => {
throw new Error("Invalid selector");
}),
};
(document.createElement as any).mockReturnValue(mockElement);
expect(isValidCssSelector("..invalid")).toBe(false);
expect(isValidCssSelector("##invalid")).toBe(false);
});
});

View File

@@ -0,0 +1,366 @@
import { NextApiResponse } from "next";
import { describe, expect, test } from "vitest";
import { responses } from "./response";
describe("API Response Utilities", () => {
describe("successResponse", () => {
test("should return a success response with data", () => {
const testData = { message: "test" };
const response = responses.successResponse(testData);
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
return response.json().then((body) => {
expect(body).toEqual({ data: testData });
});
});
test("should include CORS headers when cors is true", () => {
const testData = { message: "test" };
const response = responses.successResponse(testData, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const testData = { message: "test" };
const customCache = "public, max-age=3600";
const response = responses.successResponse(testData, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("badRequestResponse", () => {
test("should return a bad request response", () => {
const message = "Invalid input";
const details = { field: "email" };
const response = responses.badRequestResponse(message, details);
expect(response.status).toBe(400);
return response.json().then((body) => {
expect(body).toEqual({
code: "bad_request",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Invalid input";
const response = responses.badRequestResponse(message);
expect(response.status).toBe(400);
return response.json().then((body) => {
expect(body).toEqual({
code: "bad_request",
message,
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const message = "Invalid input";
const customCache = "no-cache";
const response = responses.badRequestResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("notFoundResponse", () => {
test("should return a not found response", () => {
const resourceType = "User";
const resourceId = "123";
const response = responses.notFoundResponse(resourceType, resourceId);
expect(response.status).toBe(404);
return response.json().then((body) => {
expect(body).toEqual({
code: "not_found",
message: `${resourceType} not found`,
details: {
resource_id: resourceId,
resource_type: resourceType,
},
});
});
});
test("should handle null resourceId", () => {
const resourceType = "User";
const response = responses.notFoundResponse(resourceType, null);
expect(response.status).toBe(404);
return response.json().then((body) => {
expect(body).toEqual({
code: "not_found",
message: `${resourceType} not found`,
details: {
resource_id: null,
resource_type: resourceType,
},
});
});
});
test("should use custom cache control header when provided", () => {
const resourceType = "User";
const resourceId = "123";
const customCache = "no-cache";
const response = responses.notFoundResponse(resourceType, resourceId, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("internalServerErrorResponse", () => {
test("should return an internal server error response", () => {
const message = "Something went wrong";
const response = responses.internalServerErrorResponse(message);
expect(response.status).toBe(500);
return response.json().then((body) => {
expect(body).toEqual({
code: "internal_server_error",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Something went wrong";
const response = responses.internalServerErrorResponse(message, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Something went wrong";
const customCache = "no-cache";
const response = responses.internalServerErrorResponse(message, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("goneResponse", () => {
test("should return a gone response", () => {
const message = "Resource no longer available";
const details = { reason: "deleted" };
const response = responses.goneResponse(message, details);
expect(response.status).toBe(410);
return response.json().then((body) => {
expect(body).toEqual({
code: "gone",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource no longer available";
const response = responses.goneResponse(message);
expect(response.status).toBe(410);
return response.json().then((body) => {
expect(body).toEqual({
code: "gone",
message,
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const message = "Resource no longer available";
const customCache = "no-cache";
const response = responses.goneResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("methodNotAllowedResponse", () => {
test("should return a method not allowed response", () => {
const mockRes = {
req: { method: "PUT" },
} as NextApiResponse;
const allowedMethods = ["GET", "POST"];
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods);
expect(response.status).toBe(405);
return response.json().then((body) => {
expect(body).toEqual({
code: "method_not_allowed",
message: "The HTTP PUT method is not supported by this route.",
details: {
allowed_methods: allowedMethods,
},
});
});
});
test("should handle missing request method", () => {
const mockRes = {} as NextApiResponse;
const allowedMethods = ["GET", "POST"];
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods);
expect(response.status).toBe(405);
return response.json().then((body) => {
expect(body).toEqual({
code: "method_not_allowed",
message: "The HTTP undefined method is not supported by this route.",
details: {
allowed_methods: allowedMethods,
},
});
});
});
test("should use custom cache control header when provided", () => {
const mockRes = {
req: { method: "PUT" },
} as NextApiResponse;
const allowedMethods = ["GET", "POST"];
const customCache = "no-cache";
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("notAuthenticatedResponse", () => {
test("should return a not authenticated response", () => {
const response = responses.notAuthenticatedResponse();
expect(response.status).toBe(401);
return response.json().then((body) => {
expect(body).toEqual({
code: "not_authenticated",
message: "Not authenticated",
details: {
"x-Api-Key": "Header not provided or API Key invalid",
},
});
});
});
test("should use custom cache control header when provided", () => {
const customCache = "no-cache";
const response = responses.notAuthenticatedResponse(false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("unauthorizedResponse", () => {
test("should return an unauthorized response", () => {
const response = responses.unauthorizedResponse();
expect(response.status).toBe(401);
return response.json().then((body) => {
expect(body).toEqual({
code: "unauthorized",
message: "You are not authorized to access this resource",
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const customCache = "no-cache";
const response = responses.unauthorizedResponse(false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("forbiddenResponse", () => {
test("should return a forbidden response", () => {
const message = "Access denied";
const details = { reason: "insufficient_permissions" };
const response = responses.forbiddenResponse(message, false, details);
expect(response.status).toBe(403);
return response.json().then((body) => {
expect(body).toEqual({
code: "forbidden",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Access denied";
const response = responses.forbiddenResponse(message);
expect(response.status).toBe(403);
return response.json().then((body) => {
expect(body).toEqual({
code: "forbidden",
message,
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const message = "Access denied";
const customCache = "no-cache";
const response = responses.forbiddenResponse(message, false, undefined, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";
const response = responses.tooManyRequestsResponse(message);
expect(response.status).toBe(429);
return response.json().then((body) => {
expect(body).toEqual({
code: "too_many_requests",
message,
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const message = "Rate limit exceeded";
const customCache = "no-cache";
const response = responses.tooManyRequestsResponse(message, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from "vitest";
import { ZodError, ZodIssueCode } from "zod";
import { transformErrorToDetails } from "./validator";
describe("transformErrorToDetails", () => {
test("should transform ZodError with a single issue to details object", () => {
const error = new ZodError([
{
code: ZodIssueCode.invalid_type,
expected: "string",
received: "number",
path: ["name"],
message: "Expected string, received number",
},
]);
const details = transformErrorToDetails(error);
expect(details).toEqual({
name: "Expected string, received number",
});
});
test("should transform ZodError with multiple issues to details object", () => {
const error = new ZodError([
{
code: ZodIssueCode.invalid_type,
expected: "string",
received: "number",
path: ["name"],
message: "Expected string, received number",
},
{
code: ZodIssueCode.too_small,
minimum: 5,
type: "string",
inclusive: true,
exact: false,
message: "String must contain at least 5 character(s)",
path: ["address", "street"],
},
]);
const details = transformErrorToDetails(error);
expect(details).toEqual({
name: "Expected string, received number",
"address.street": "String must contain at least 5 character(s)",
});
});
test("should return an empty object if ZodError has no issues", () => {
const error = new ZodError([]);
const details = transformErrorToDetails(error);
expect(details).toEqual({});
});
test("should handle issues with empty paths", () => {
const error = new ZodError([
{
code: ZodIssueCode.custom,
path: [],
message: "Global error",
},
]);
const details = transformErrorToDetails(error);
expect(details).toEqual({
"": "Global error",
});
});
test("should handle issues with multi-level paths", () => {
const error = new ZodError([
{
code: ZodIssueCode.invalid_type,
expected: "string",
received: "undefined",
path: ["user", "profile", "firstName"],
message: "Required",
},
]);
const details = transformErrorToDetails(error);
expect(details).toEqual({
"user.profile.firstName": "Required",
});
});
});

View File

@@ -0,0 +1,736 @@
import {
DateRange,
SelectedFilterValue,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
describe("surveys", () => {
afterEach(() => {
cleanup();
});
describe("generateQuestionAndFilterOptions", () => {
test("should return question options for basic survey without additional options", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text Question" },
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
expect(result.questionOptions.length).toBeGreaterThan(0);
expect(result.questionOptions[0].header).toBe(OptionsType.QUESTIONS);
expect(result.questionFilterOptions.length).toBe(1);
expect(result.questionFilterOptions[0].id).toBe("q1");
});
test("should include tags in options when provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const tags: TTag[] = [
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
];
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {});
const tagsHeader = result.questionOptions.find((opt) => opt.header === OptionsType.TAGS);
expect(tagsHeader).toBeDefined();
expect(tagsHeader?.option.length).toBe(1);
expect(tagsHeader?.option[0].label).toBe("Tag 1");
});
test("should include attributes in options when provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const attributes = {
role: ["admin", "user"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {});
const attributesHeader = result.questionOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
expect(attributesHeader).toBeDefined();
expect(attributesHeader?.option.length).toBe(1);
expect(attributesHeader?.option[0].label).toBe("role");
});
test("should include meta in options when provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const meta = {
source: ["web", "mobile"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
const metaHeader = result.questionOptions.find((opt) => opt.header === OptionsType.META);
expect(metaHeader).toBeDefined();
expect(metaHeader?.option.length).toBe(1);
expect(metaHeader?.option[0].label).toBe("source");
});
test("should include hidden fields in options when provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const hiddenFields = {
segment: ["free", "paid"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields);
const hiddenFieldsHeader = result.questionOptions.find(
(opt) => opt.header === OptionsType.HIDDEN_FIELDS
);
expect(hiddenFieldsHeader).toBeDefined();
expect(hiddenFieldsHeader?.option.length).toBe(1);
expect(hiddenFieldsHeader?.option[0].label).toBe("segment");
});
test("should include language options when survey has languages", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
const othersHeader = result.questionOptions.find((opt) => opt.header === OptionsType.OTHERS);
expect(othersHeader).toBeDefined();
expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy();
});
test("should handle all question types correctly", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text" },
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Multiple Choice Single" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "q3",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multiple Choice Multi" },
choices: [
{ id: "c1", label: "Choice 1" },
{ id: "other", label: "Other" },
],
} as unknown as TSurveyQuestion,
{
id: "q4",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS" },
} as unknown as TSurveyQuestion,
{
id: "q5",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating" },
} as unknown as TSurveyQuestion,
{
id: "q6",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
} as unknown as TSurveyQuestion,
{
id: "q7",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Picture Selection" },
choices: [
{ id: "p1", imageUrl: "url1" },
{ id: "p2", imageUrl: "url2" },
],
} as unknown as TSurveyQuestion,
{
id: "q8",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
expect(result.questionFilterOptions.length).toBe(8);
expect(result.questionFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
expect(result.questionFilterOptions.some((o) => o.id === "q2")).toBeTruthy();
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
});
});
describe("getFormattedFilters", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "openTextQ",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text" },
} as unknown as TSurveyQuestion,
{
id: "mcSingleQ",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Multiple Choice Single" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "mcMultiQ",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multiple Choice Multi" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "npsQ",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS" },
} as unknown as TSurveyQuestion,
{
id: "ratingQ",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating" },
} as unknown as TSurveyQuestion,
{
id: "ctaQ",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
} as unknown as TSurveyQuestion,
{
id: "consentQ",
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent" },
} as unknown as TSurveyQuestion,
{
id: "pictureQ",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Picture Selection" },
choices: [
{ id: "p1", imageUrl: "url1" },
{ id: "p2", imageUrl: "url2" },
],
} as unknown as TSurveyQuestion,
{
id: "matrixQ",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
} as unknown as TSurveyQuestion,
{
id: "addressQ",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Address" },
} as unknown as TSurveyQuestion,
{
id: "contactQ",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Contact Info" },
} as unknown as TSurveyQuestion,
{
id: "rankingQ",
type: TSurveyQuestionTypeEnum.Ranking,
headline: { default: "Ranking" },
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const dateRange: DateRange = {
from: new Date("2023-01-01"),
to: new Date("2023-01-31"),
};
test("should return empty filters when no selections", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [],
};
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(Object.keys(result).length).toBe(0);
});
test("should filter by completed responses", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: true,
filter: [],
};
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.finished).toBe(true);
});
test("should filter by date range", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [],
};
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.createdAt).toBeDefined();
expect(result.createdAt?.min).toEqual(dateRange.from);
expect(result.createdAt?.max).toEqual(dateRange.to);
});
test("should filter by tags", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
filterType: { filterComboBoxValue: "Applied" },
},
{
questionType: { type: "Tags", label: "Tag 2", id: "tag2" },
filterType: { filterComboBoxValue: "Not applied" },
},
] as any,
};
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.tags?.applied).toContain("Tag 1");
expect(result.tags?.notApplied).toContain("Tag 2");
});
test("should filter by open text questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Open Text",
id: "openTextQ",
questionType: TSurveyQuestionTypeEnum.OpenText,
},
filterType: { filterComboBoxValue: "Filled out" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.openTextQ).toEqual({ op: "filledOut" });
});
test("should filter by address questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Address",
id: "addressQ",
questionType: TSurveyQuestionTypeEnum.Address,
},
filterType: { filterComboBoxValue: "Skipped" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.addressQ).toEqual({ op: "skipped" });
});
test("should filter by contact info questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Contact Info",
id: "contactQ",
questionType: TSurveyQuestionTypeEnum.ContactInfo,
},
filterType: { filterComboBoxValue: "Filled out" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.contactQ).toEqual({ op: "filledOut" });
});
test("should filter by ranking questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Ranking",
id: "rankingQ",
questionType: TSurveyQuestionTypeEnum.Ranking,
},
filterType: { filterComboBoxValue: "Filled out" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.rankingQ).toEqual({ op: "submitted" });
});
test("should filter by multiple choice single questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "MC Single",
id: "mcSingleQ",
questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
},
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.mcSingleQ).toEqual({ op: "includesOne", value: ["Choice 1"] });
});
test("should filter by multiple choice multi questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "MC Multi",
id: "mcMultiQ",
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
},
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.mcMultiQ).toEqual({ op: "includesAll", value: ["Choice 1", "Choice 2"] });
});
test("should filter by NPS questions with different operations", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "NPS",
id: "npsQ",
questionType: TSurveyQuestionTypeEnum.NPS,
},
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.npsQ).toEqual({ op: "equals", value: 7 });
});
test("should filter by rating questions with less than operation", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Rating",
id: "ratingQ",
questionType: TSurveyQuestionTypeEnum.Rating,
},
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.ratingQ).toEqual({ op: "lessThan", value: 4 });
});
test("should filter by CTA questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "CTA",
id: "ctaQ",
questionType: TSurveyQuestionTypeEnum.CTA,
},
filterType: { filterComboBoxValue: "Clicked" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.ctaQ).toEqual({ op: "clicked" });
});
test("should filter by consent questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Consent",
id: "consentQ",
questionType: TSurveyQuestionTypeEnum.Consent,
},
filterType: { filterComboBoxValue: "Accepted" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.consentQ).toEqual({ op: "accepted" });
});
test("should filter by picture selection questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Picture",
id: "pictureQ",
questionType: TSurveyQuestionTypeEnum.PictureSelection,
},
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.pictureQ).toEqual({ op: "includesOne", value: ["p1"] });
});
test("should filter by matrix questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Matrix",
id: "matrixQ",
questionType: TSurveyQuestionTypeEnum.Matrix,
},
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.matrixQ).toEqual({ op: "matrix", value: { "Row 1": "Column 1" } });
});
test("should filter by hidden fields", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Hidden Fields", label: "plan", id: "plan" },
filterType: { filterValue: "Equals", filterComboBoxValue: "pro" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.plan).toEqual({ op: "equals", value: "pro" });
});
test("should filter by attributes", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Attributes", label: "role", id: "role" },
filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.contactAttributes?.role).toEqual({ op: "notEquals", value: "admin" });
});
test("should filter by other filters", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Other Filters", label: "Language", id: "language" },
filterType: { filterValue: "Equals", filterComboBoxValue: "en" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.others?.Language).toEqual({ op: "equals", value: "en" });
});
test("should filter by meta fields", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Not equals", filterComboBoxValue: "web" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.meta?.source).toEqual({ op: "notEquals", value: "web" });
});
test("should handle multiple filters together", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: true,
filter: [
{
questionType: {
type: "Questions",
label: "NPS",
id: "npsQ",
questionType: TSurveyQuestionTypeEnum.NPS,
},
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
},
{
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
filterType: { filterComboBoxValue: "Applied" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.finished).toBe(true);
expect(result.createdAt).toBeDefined();
expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 });
expect(result.tags?.applied).toContain("Tag 1");
});
});
describe("getTodayDate", () => {
test("should return today's date with time set to end of day", () => {
const today = new Date();
const result = getTodayDate();
expect(result.getFullYear()).toBe(today.getFullYear());
expect(result.getMonth()).toBe(today.getMonth());
expect(result.getDate()).toBe(today.getDate());
expect(result.getHours()).toBe(23);
expect(result.getMinutes()).toBe(59);
expect(result.getSeconds()).toBe(59);
expect(result.getMilliseconds()).toBe(999);
});
});
});

View File

@@ -0,0 +1,99 @@
import * as constants from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
describe("bucket middleware rate limiters", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
const mockedRateLimit = rateLimit as unknown as Mock;
mockedRateLimit.mockImplementation((config) => config);
});
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
const { loginLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
expect(loginLimiter).toEqual({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
});
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
const { signupLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
expect(signupLimiter).toEqual({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
});
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
const { verifyEmailLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
expect(verifyEmailLimiter).toEqual({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
});
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
const { forgotPasswordLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
expect(forgotPasswordLimiter).toEqual({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
});
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
const { clientSideApiEndpointsLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
expect(clientSideApiEndpointsLimiter).toEqual({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
});
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
const { shareUrlLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
expect(shareUrlLimiter).toEqual({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
});
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
const { syncUserIdentificationLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
expect(syncUserIdentificationLimiter).toEqual({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
});
});

View File

@@ -1,4 +1,3 @@
import { rateLimit } from "@/app/middleware/rate-limit";
import {
CLIENT_SIDE_API_RATE_LIMIT,
FORGET_PASSWORD_RATE_LIMIT,
@@ -8,6 +7,7 @@ import {
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
VERIFY_EMAIL_RATE_LIMIT,
} from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,

View File

@@ -0,0 +1,140 @@
import { describe, expect, test } from "vitest";
import {
isAuthProtectedRoute,
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "./endpoint-validator";
describe("endpoint-validator", () => {
describe("isLoginRoute", () => {
test("should return true for login routes", () => {
expect(isLoginRoute("/api/auth/callback/credentials")).toBe(true);
expect(isLoginRoute("/auth/login")).toBe(true);
});
test("should return false for non-login routes", () => {
expect(isLoginRoute("/auth/signup")).toBe(false);
expect(isLoginRoute("/api/something")).toBe(false);
});
});
describe("isSignupRoute", () => {
test("should return true for signup route", () => {
expect(isSignupRoute("/auth/signup")).toBe(true);
});
test("should return false for non-signup routes", () => {
expect(isSignupRoute("/auth/login")).toBe(false);
expect(isSignupRoute("/api/something")).toBe(false);
});
});
describe("isVerifyEmailRoute", () => {
test("should return true for verify email route", () => {
expect(isVerifyEmailRoute("/auth/verify-email")).toBe(true);
});
test("should return false for non-verify email routes", () => {
expect(isVerifyEmailRoute("/auth/login")).toBe(false);
expect(isVerifyEmailRoute("/api/something")).toBe(false);
});
});
describe("isForgotPasswordRoute", () => {
test("should return true for forgot password route", () => {
expect(isForgotPasswordRoute("/auth/forgot-password")).toBe(true);
});
test("should return false for non-forgot password routes", () => {
expect(isForgotPasswordRoute("/auth/login")).toBe(false);
expect(isForgotPasswordRoute("/api/something")).toBe(false);
});
});
describe("isClientSideApiRoute", () => {
test("should return true for client-side API routes", () => {
expect(isClientSideApiRoute("/api/packages/something")).toBe(true);
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
expect(isClientSideApiRoute("/api/v2/client/other")).toBe(true);
});
test("should return false for non-client-side API routes", () => {
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
expect(isClientSideApiRoute("/api/something")).toBe(false);
expect(isClientSideApiRoute("/auth/login")).toBe(false);
});
});
describe("isManagementApiRoute", () => {
test("should return true for management API routes", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toBe(true);
expect(isManagementApiRoute("/api/v2/management/other")).toBe(true);
});
test("should return false for non-management API routes", () => {
expect(isManagementApiRoute("/api/v1/client/something")).toBe(false);
expect(isManagementApiRoute("/api/something")).toBe(false);
expect(isManagementApiRoute("/auth/login")).toBe(false);
});
});
describe("isShareUrlRoute", () => {
test("should return true for share URL routes", () => {
expect(isShareUrlRoute("/share/abc123/summary")).toBe(true);
expect(isShareUrlRoute("/share/abc123/responses")).toBe(true);
expect(isShareUrlRoute("/share/abc123def456/summary")).toBe(true);
});
test("should return false for non-share URL routes", () => {
expect(isShareUrlRoute("/share/abc123")).toBe(false);
expect(isShareUrlRoute("/share/abc123/other")).toBe(false);
expect(isShareUrlRoute("/api/something")).toBe(false);
});
});
describe("isAuthProtectedRoute", () => {
test("should return true for protected routes", () => {
expect(isAuthProtectedRoute("/environments")).toBe(true);
expect(isAuthProtectedRoute("/environments/something")).toBe(true);
expect(isAuthProtectedRoute("/setup/organization")).toBe(true);
expect(isAuthProtectedRoute("/organizations")).toBe(true);
expect(isAuthProtectedRoute("/organizations/something")).toBe(true);
});
test("should return false for non-protected routes", () => {
expect(isAuthProtectedRoute("/auth/login")).toBe(false);
expect(isAuthProtectedRoute("/api/something")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
});
});
describe("isSyncWithUserIdentificationEndpoint", () => {
test("should return environmentId and userId for valid sync URLs", () => {
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
expect(result2).toEqual({
environmentId: "abc-123",
userId: "xyz-789",
});
});
test("should return false for invalid sync URLs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
});
});
});

View File

@@ -1,4 +1,5 @@
export const isLoginRoute = (url: string) => url === "/api/auth/callback/credentials";
export const isLoginRoute = (url: string) =>
url === "/api/auth/callback/credentials" || url === "/auth/login";
export const isSignupRoute = (url: string) => url === "/auth/signup";

View File

@@ -0,0 +1,37 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/preact";
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import NotFound from "./not-found";
describe("NotFound", () => {
afterEach(() => {
cleanup();
});
test("renders 404 page with correct content", () => {
render(<NotFound />);
// Check for the 404 text
const errorCode = screen.getByTestId("error-code");
expect(errorCode).toBeInTheDocument();
expect(errorCode).toHaveClass("text-sm", "font-semibold");
expect(errorCode).toHaveTextContent("404");
// Check for the heading
const heading = screen.getByRole("heading", { name: "Page not found" });
expect(heading).toBeInTheDocument();
expect(heading).toHaveClass("mt-2", "text-2xl", "font-bold");
// Check for the error message
const errorMessage = screen.getByTestId("error-message");
expect(errorMessage).toBeInTheDocument();
expect(errorMessage).toHaveClass("mt-2", "text-base");
expect(errorMessage).toHaveTextContent("Sorry, we couldn't find the page you're looking for.");
// Check for the button
const button = screen.getByRole("button", { name: "Back to home" });
expect(button).toBeInTheDocument();
expect(button).toHaveClass("mt-8");
});
});

View File

@@ -3,18 +3,18 @@ import Link from "next/link";
const NotFound = () => {
return (
<>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">Page not found</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
Sorry, we couldnt find the page youre looking for.
</p>
<Link href={"/"}>
<Button className="mt-8">Back to home</Button>
</Link>
</div>
</>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white" data-testid="error-code">
404
</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">Page not found</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400" data-testid="error-message">
Sorry, we couldn&apos;t find the page you&apos;re looking for.
</p>
<Link href={"/"}>
<Button className="mt-8">Back to home</Button>
</Link>
</div>
);
};

391
apps/web/app/page.test.tsx Normal file
View File

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

View File

@@ -17,9 +17,9 @@ const Page = async () => {
if (!session) {
if (isFreshInstance) {
redirect("/setup/intro");
return redirect("/setup/intro");
} else {
redirect("/auth/login");
return redirect("/auth/login");
}
}

View File

@@ -38,7 +38,7 @@ describe("SentryProvider", () => {
expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: sentryDsn,
tracesSampleRate: 1,
tracesSampleRate: 0,
debug: false,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
@@ -81,6 +81,26 @@ describe("SentryProvider", () => {
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
});
test("does not reinitialize Sentry when props change after initial render", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
const { rerender } = render(
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledTimes(1);
rerender(
<SentryProvider sentryDsn="https://newDsn@o0.ingest.sentry.io/0" isEnabled={false}>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledTimes(1);
});
test("processes beforeSend correctly", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
@@ -109,4 +129,36 @@ describe("SentryProvider", () => {
const hintWithoutError = { originalException: undefined };
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
});
test("processes beforeSend correctly when hint.originalException is not an Error object", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
render(
<SentryProvider sentryDsn={sentryDsn} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
const config = initSpy.mock.calls[0][0];
expect(config).toHaveProperty("beforeSend");
const beforeSend = config.beforeSend;
if (!beforeSend) {
throw new Error("beforeSend is not defined");
}
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
const hintWithString = { originalException: "string exception" };
expect(() => beforeSend(dummyEvent, hintWithString)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithString)).toEqual(dummyEvent);
const hintWithNumber = { originalException: 123 };
expect(() => beforeSend(dummyEvent, hintWithNumber)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithNumber)).toEqual(dummyEvent);
const hintWithNull = { originalException: null };
expect(() => beforeSend(dummyEvent, hintWithNull)).not.toThrow();
expect(beforeSend(dummyEvent, hintWithNull)).toEqual(dummyEvent);
});
});

View File

@@ -15,8 +15,8 @@ export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProvide
Sentry.init({
dsn: sentryDsn,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,

View File

@@ -5,18 +5,16 @@ import Link from "next/link";
const NotFound = async () => {
const t = await getTranslate();
return (
<>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">{t("share.page_not_found")}</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
{t("share.page_not_found_description")}
</p>
<Link href={"/"}>
<Button className="mt-8">{t("share.back_to_home")}</Button>
</Link>
</div>
</>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">{t("share.page_not_found")}</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
{t("share.page_not_found_description")}
</p>
<Link href={"/"}>
<Button className="mt-8">{t("share.back_to_home")}</Button>
</Link>
</div>
);
};

97
apps/web/cache-handler.js Normal file
View File

@@ -0,0 +1,97 @@
// This cache handler follows the @fortedigital/nextjs-cache-handler example
// Read more at: https://github.com/fortedigital/nextjs-cache-handler
// @neshca/cache-handler dependencies
const { CacheHandler } = require("@neshca/cache-handler");
const createLruHandler = require("@neshca/cache-handler/local-lru").default;
// Next/Redis dependencies
const { createClient } = require("redis");
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
// @fortedigital/nextjs-cache-handler dependencies
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
// Usual onCreation from @neshca/cache-handler
CacheHandler.onCreation(() => {
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures only one instance get created
if (global.cacheHandlerConfig) {
return global.cacheHandlerConfig;
}
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures new instances are not created in a race condition
if (global.cacheHandlerConfigPromise) {
return global.cacheHandlerConfigPromise;
}
// If REDIS_URL is not set, we will use LRU cache only
if (!process.env.REDIS_URL) {
const lruCache = createLruHandler();
return { handlers: [lruCache] };
}
// Main promise initializing the handler
global.cacheHandlerConfigPromise = (async () => {
/** @type {import("redis").RedisClientType | null} */
let redisClient = null;
// eslint-disable-next-line turbo/no-undeclared-env-vars -- Next.js will inject this variable
if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
const settings = {
url: process.env.REDIS_URL, // Make sure you configure this variable
pingInterval: 10000,
};
try {
redisClient = createClient(settings);
redisClient.on("error", (e) => {
console.error("Redis error", e);
global.cacheHandlerConfig = null;
global.cacheHandlerConfigPromise = null;
});
} catch (error) {
console.error("Failed to create Redis client:", error);
}
}
if (redisClient) {
try {
console.info("Connecting Redis client...");
await redisClient.connect();
console.info("Redis client connected.");
} catch (error) {
console.error("Failed to connect Redis client:", error);
await redisClient
.disconnect()
.catch(() => console.error("Failed to quit the Redis client after failing to connect."));
}
}
const lruCache = createLruHandler();
if (!redisClient?.isReady) {
console.error("Failed to initialize caching layer.");
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = { handlers: [lruCache] };
return global.cacheHandlerConfig;
}
const redisCacheHandler = createRedisHandler({
client: redisClient,
keyPrefix: "nextjs:",
});
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = {
handlers: [redisCacheHandler],
};
return global.cacheHandlerConfig;
})();
return global.cacheHandlerConfigPromise;
});
module.exports = new Next15CacheHandler();

View File

@@ -1,79 +0,0 @@
import { CacheHandler } from "@neshca/cache-handler";
import createLruHandler from "@neshca/cache-handler/local-lru";
import createRedisHandler from "@neshca/cache-handler/redis-strings";
import { createClient } from "redis";
// Function to create a timeout promise
const createTimeoutPromise = (ms, rejectReason) => {
return new Promise((_, reject) => setTimeout(() => reject(new Error(rejectReason)), ms));
};
CacheHandler.onCreation(async () => {
let client;
if (process.env.REDIS_URL) {
try {
// Create a Redis client.
client = createClient({
url: process.env.REDIS_URL,
});
// Redis won't work without error handling.
client.on("error", () => {});
} catch (error) {
console.warn("Failed to create Redis client:", error);
}
if (client) {
try {
// Wait for the client to connect with a timeout of 5000ms.
const connectPromise = client.connect();
const timeoutPromise = createTimeoutPromise(5000, "Redis connection timed out"); // 5000ms timeout
await Promise.race([connectPromise, timeoutPromise]);
} catch (error) {
console.warn("Failed to connect Redis client:", error);
console.warn("Disconnecting the Redis client...");
// Try to disconnect the client to stop it from reconnecting.
client
.disconnect()
.then(() => {
console.info("Redis client disconnected.");
})
.catch(() => {
console.warn("Failed to quit the Redis client after failing to connect.");
});
}
}
}
/** @type {import("@neshca/cache-handler").Handler | null} */
let handler;
if (client?.isReady) {
const redisHandlerOptions = {
client,
keyPrefix: "fb:",
timeoutMs: 1000,
};
// Create the `redis-stack` Handler if the client is available and connected.
handler = await createRedisHandler(redisHandlerOptions);
} else {
// Fallback to LRU handler if Redis client is not available.
// The application will still work, but the cache will be in memory only and not shared.
handler = createLruHandler();
console.log("Using LRU handler for caching.");
}
return {
handlers: [handler],
ttl: {
// We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation.
defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400,
estimateExpireAge: (staleAge) => staleAge,
},
};
});
export default CacheHandler;

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