Compare commits

...

48 Commits

Author SHA1 Message Date
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
587 changed files with 50991 additions and 3419 deletions

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

@@ -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

@@ -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

@@ -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

@@ -390,7 +390,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

@@ -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

@@ -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;

View File

@@ -0,0 +1,213 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { actionClassCache } from "./cache";
import {
deleteActionClass,
getActionClass,
getActionClassByEnvironmentIdAndName,
getActionClasses,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("../utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("../cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("./cache", () => ({
actionClassCache: {
tag: {
byEnvironmentId: vi.fn(),
byNameAndEnvironmentId: vi.fn(),
byId: vi.fn(),
},
revalidate: vi.fn(),
},
}));
describe("ActionClass Service", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("getActionClasses", () => {
test("should return action classes for environment", async () => {
const mockActionClasses = [
{
id: "id1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 1",
description: "desc",
type: "code",
key: "key1",
noCodeConfig: {},
environmentId: "env1",
},
];
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
vi.mocked(actionClassCache.tag.byEnvironmentId).mockReturnValue("mock-tag");
const result = await getActionClasses("env1");
expect(result).toEqual(mockActionClasses);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: { environmentId: "env1" },
select: expect.any(Object),
take: undefined,
skip: undefined,
orderBy: { createdAt: "asc" },
});
});
test("should throw DatabaseError when prisma throws", async () => {
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("fail"));
vi.mocked(actionClassCache.tag.byEnvironmentId).mockReturnValue("mock-tag");
await expect(getActionClasses("env1")).rejects.toThrow(DatabaseError);
});
});
describe("getActionClassByEnvironmentIdAndName", () => {
test("should return action class when found", async () => {
const mockActionClass = {
id: "id2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 2",
description: "desc2",
type: "noCode",
key: null,
noCodeConfig: {},
environmentId: "env2",
};
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass);
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.findFirst).toHaveBeenCalledWith({
where: { name: "Action 2", environmentId: "env2" },
select: expect.any(Object),
});
});
test("should return null when not found", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null);
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail"));
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
await expect(getActionClassByEnvironmentIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
});
});
describe("getActionClass", () => {
test("should return action class when found", async () => {
const mockActionClass = {
id: "id3",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 3",
description: "desc3",
type: "code",
key: "key3",
noCodeConfig: {},
environmentId: "env3",
};
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass);
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
const result = await getActionClass("id3");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.findUnique).toHaveBeenCalledWith({
where: { id: "id3" },
select: expect.any(Object),
});
});
test("should return null when not found", async () => {
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(null);
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
const result = await getActionClass("id3");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
vi.mocked(prisma.actionClass.findUnique).mockRejectedValue(new Error("fail"));
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
await expect(getActionClass("id3")).rejects.toThrow(DatabaseError);
});
});
describe("deleteActionClass", () => {
test("should delete and return action class", async () => {
const mockActionClass: TActionClass = {
id: "id4",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 4",
description: null,
type: "code",
key: "key4",
noCodeConfig: null,
environmentId: "env4",
};
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
vi.mocked(prisma.actionClass.delete).mockResolvedValue(mockActionClass);
vi.mocked(actionClassCache.revalidate).mockReturnValue(undefined);
const result = await deleteActionClass("id4");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.delete).toHaveBeenCalledWith({
where: { id: "id4" },
select: expect.any(Object),
});
expect(actionClassCache.revalidate).toHaveBeenCalledWith({
environmentId: mockActionClass.environmentId,
id: "id4",
name: mockActionClass.name,
});
});
test("should throw ResourceNotFoundError if action class is null", async () => {
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
vi.mocked(prisma.actionClass.delete).mockResolvedValue(null as unknown as TActionClass);
await expect(deleteActionClass("id4")).rejects.toThrow(ResourceNotFoundError);
});
test("should rethrow unknown errors", async () => {
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
const error = new Error("unknown");
vi.mocked(prisma.actionClass.delete).mockRejectedValue(error);
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
});
});
});

View File

@@ -150,7 +150,12 @@ export const createActionClass = async (
...actionClassInput,
environment: { connect: { id: environmentId } },
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined,
noCodeConfig:
actionClassInput.type === "noCode"
? actionClassInput.noCodeConfig === null
? undefined
: actionClassInput.noCodeConfig
: undefined,
},
select: selectActionClass,
});
@@ -193,7 +198,12 @@ export const updateActionClass = async (
...actionClassInput,
environment: { connect: { id: environmentId } },
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined,
noCodeConfig:
actionClassInput.type === "noCode"
? actionClassInput.noCodeConfig === null
? undefined
: actionClassInput.noCodeConfig
: undefined,
},
select: {
...selectActionClass,
@@ -212,7 +222,6 @@ export const updateActionClass = async (
id: result.id,
});
// @ts-expect-error
const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId);
for (const surveyId of surveyIds) {
surveyCache.revalidate({

219
apps/web/lib/auth.test.ts Normal file
View File

@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { AuthenticationError } from "@formbricks/types/errors";
import {
hasOrganizationAccess,
hasOrganizationAuthority,
hasOrganizationOwnership,
hashPassword,
isManagerOrOwner,
isOwner,
verifyPassword,
} from "./auth";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
findUnique: vi.fn(),
},
},
}));
describe("Password Management", () => {
test("hashPassword should hash a password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
});
test("verifyPassword should verify a correct password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
test("verifyPassword should reject an incorrect password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
});
});
describe("Organization Access", () => {
const mockUserId = "user123";
const mockOrgId = "org123";
beforeEach(() => {
vi.resetAllMocks();
});
test("hasOrganizationAccess should return true when user has membership", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
});
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
expect(hasAccess).toBe(true);
});
test("hasOrganizationAccess should return false when user has no membership", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
expect(hasAccess).toBe(false);
});
test("isManagerOrOwner should return true for manager role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
});
const isManager = await isManagerOrOwner(mockUserId, mockOrgId);
expect(isManager).toBe(true);
});
test("isManagerOrOwner should return true for owner role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwner = await isManagerOrOwner(mockUserId, mockOrgId);
expect(isOwner).toBe(true);
});
test("isManagerOrOwner should return false for member role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
});
const isManagerOrOwnerRole = await isManagerOrOwner(mockUserId, mockOrgId);
expect(isManagerOrOwnerRole).toBe(false);
});
test("isOwner should return true only for owner role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
expect(isOwnerRole).toBe(true);
});
test("isOwner should return false for non-owner roles", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
expect(isOwnerRole).toBe(false);
});
});
describe("Organization Authority", () => {
const mockUserId = "user123";
const mockOrgId = "org123";
beforeEach(() => {
vi.resetAllMocks();
});
test("hasOrganizationAuthority should return true for manager", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
});
const hasAuthority = await hasOrganizationAuthority(mockUserId, mockOrgId);
expect(hasAuthority).toBe(true);
});
test("hasOrganizationAuthority should throw for non-member", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
});
test("hasOrganizationAuthority should throw for member role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
});
test("hasOrganizationOwnership should return true for owner", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
});
const hasOwnership = await hasOrganizationOwnership(mockUserId, mockOrgId);
expect(hasOwnership).toBe(true);
});
test("hasOrganizationOwnership should throw for non-member", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
});
test("hasOrganizationOwnership should throw for non-owner roles", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
});
});

View File

@@ -205,7 +205,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const REDIS_URL = env.REDIS_URL;
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
export const REDIS_DEFAULT_TTL = env.REDIS_DEFAULT_TTL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;
@@ -283,4 +282,4 @@ export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
export const DISABLE_USER_MANAGEMENT = env.DISABLE_USER_MANAGEMENT === "1";
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";

View File

@@ -56,7 +56,6 @@ export const env = createEnv({
OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
REDIS_URL: z.string().optional(),
REDIS_DEFAULT_TTL: z.string().optional(),
REDIS_HTTP_URL: z.string().optional(),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
POSTHOG_API_HOST: z.string().optional(),
@@ -105,7 +104,7 @@ export const env = createEnv({
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
DISABLE_USER_MANAGEMENT: z.enum(["1", "0"]).optional(),
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
},
/*
@@ -163,7 +162,6 @@ export const env = createEnv({
OIDC_ISSUER: process.env.OIDC_ISSUER,
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
REDIS_URL: process.env.REDIS_URL,
REDIS_DEFAULT_TTL: process.env.REDIS_DEFAULT_TTL,
REDIS_HTTP_URL: process.env.REDIS_HTTP_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
PRIVACY_URL: process.env.PRIVACY_URL,
@@ -201,6 +199,6 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
DISABLE_USER_MANAGEMENT: process.env.DISABLE_USER_MANAGEMENT,
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
},
});

View File

@@ -0,0 +1,86 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "./auth";
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
findFirst: vi.fn(),
},
teamUser: {
findFirst: vi.fn(),
},
},
}));
describe("hasUserEnvironmentAccess", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns true for owner role", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "owner",
} as any);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(true);
});
test("returns true for manager role", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "manager",
} as any);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(true);
});
test("returns true for billing role", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "billing",
} as any);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(true);
});
test("returns true when user has team membership", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "member",
} as any);
vi.mocked(prisma.teamUser.findFirst).mockResolvedValue({
userId: "user1",
} as any);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(true);
});
test("returns false when user has no access", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "member",
} as any);
vi.mocked(prisma.teamUser.findFirst).mockResolvedValue(null);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(false);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.membership.findFirst).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "1.0.0",
})
);
await expect(hasUserEnvironmentAccess("user1", "env1")).rejects.toThrow(DatabaseError);
});
});

View File

@@ -0,0 +1,206 @@
import { EnvironmentType, Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { environmentCache } from "./cache";
import { getEnvironment, getEnvironments, updateEnvironment } from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
update: vi.fn(),
},
project: {
findFirst: vi.fn(),
},
},
}));
vi.mock("../utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("../cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("./cache", () => ({
environmentCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn(),
byProjectId: vi.fn(),
},
},
}));
describe("Environment Service", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("getEnvironment", () => {
test("should return environment when found", async () => {
const mockEnvironment = {
id: "clh6pzwx90000e9ogjr0mf7sx",
type: EnvironmentType.production,
projectId: "clh6pzwx90000e9ogjr0mf7sy",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
widgetSetupCompleted: false,
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironment);
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx");
expect(result).toEqual(mockEnvironment);
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: {
id: "clh6pzwx90000e9ogjr0mf7sx",
},
});
});
test("should return null when environment not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
await expect(getEnvironment("clh6pzwx90000e9ogjr0mf7sx")).rejects.toThrow(DatabaseError);
});
});
describe("getEnvironments", () => {
test("should return environments when project exists", async () => {
const mockEnvironments = [
{
id: "clh6pzwx90000e9ogjr0mf7sx",
type: EnvironmentType.production,
projectId: "clh6pzwx90000e9ogjr0mf7sy",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
},
{
id: "clh6pzwx90000e9ogjr0mf7sz",
type: EnvironmentType.development,
projectId: "clh6pzwx90000e9ogjr0mf7sy",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: true,
},
];
vi.mocked(prisma.project.findFirst).mockResolvedValue({
id: "clh6pzwx90000e9ogjr0mf7sy",
name: "Test Project",
environments: [
{
...mockEnvironments[0],
widgetSetupCompleted: false,
},
{
...mockEnvironments[1],
widgetSetupCompleted: true,
},
],
});
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
const result = await getEnvironments("clh6pzwx90000e9ogjr0mf7sy");
expect(result).toEqual(mockEnvironments);
expect(prisma.project.findFirst).toHaveBeenCalledWith({
where: {
id: "clh6pzwx90000e9ogjr0mf7sy",
},
include: {
environments: true,
},
});
});
test("should throw ResourceNotFoundError when project not found", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError when prisma throws", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(DatabaseError);
});
});
describe("updateEnvironment", () => {
test("should update environment successfully", async () => {
const mockEnvironment = {
id: "clh6pzwx90000e9ogjr0mf7sx",
type: EnvironmentType.production,
projectId: "clh6pzwx90000e9ogjr0mf7sy",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
widgetSetupCompleted: false,
};
vi.mocked(prisma.environment.update).mockResolvedValue(mockEnvironment);
const updateData = {
appSetupCompleted: true,
};
const result = await updateEnvironment("clh6pzwx90000e9ogjr0mf7sx", updateData);
expect(result).toEqual(mockEnvironment);
expect(prisma.environment.update).toHaveBeenCalledWith({
where: {
id: "clh6pzwx90000e9ogjr0mf7sx",
},
data: expect.objectContaining({
appSetupCompleted: true,
updatedAt: expect.any(Date),
}),
});
expect(environmentCache.revalidate).toHaveBeenCalledWith({
id: "clh6pzwx90000e9ogjr0mf7sx",
projectId: "clh6pzwx90000e9ogjr0mf7sy",
});
});
test("should throw DatabaseError when prisma throws", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.update).mockRejectedValue(prismaError);
await expect(
updateEnvironment("clh6pzwx90000e9ogjr0mf7sx", { appSetupCompleted: true })
).rejects.toThrow(DatabaseError);
});
});
});

View File

@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
// Create a mock module for constants with proper types
const constantsMock = {
SURVEY_URL: undefined as string | undefined,
WEBAPP_URL: "http://localhost:3000" as string,
};
// Mock the constants module
vi.mock("./constants", () => constantsMock);
describe("getSurveyDomain", () => {
beforeEach(() => {
// Reset the mock values before each test
constantsMock.SURVEY_URL = undefined;
constantsMock.WEBAPP_URL = "http://localhost:3000";
vi.resetModules();
});
test("should return WEBAPP_URL when SURVEY_URL is not set", async () => {
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
test("should return SURVEY_URL when it is set", async () => {
constantsMock.SURVEY_URL = "https://surveys.example.com";
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("https://surveys.example.com");
});
test("should handle empty string SURVEY_URL by returning WEBAPP_URL", async () => {
constantsMock.SURVEY_URL = "";
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
test("should handle undefined SURVEY_URL by returning WEBAPP_URL", async () => {
constantsMock.SURVEY_URL = undefined;
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
});

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { hashString } from "./hashString";
describe("hashString", () => {
test("should return a string", () => {
const input = "test string";
const hash = hashString(input);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("should produce consistent hashes for the same input", () => {
const input = "test string";
const hash1 = hashString(input);
const hash2 = hashString(input);
expect(hash1).toBe(hash2);
});
test("should handle empty strings", () => {
const hash = hashString("");
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("should handle special characters", () => {
const input = "!@#$%^&*()_+{}|:<>?";
const hash = hashString(input);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("should handle unicode characters", () => {
const input = "Hello, 世界!";
const hash = hashString(input);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("should handle long strings", () => {
const input = "a".repeat(1000);
const hash = hashString(input);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
});

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