Compare commits

..

57 Commits

Author SHA1 Message Date
Dhruwang
5cbfc6956b fix 2025-05-06 14:00:20 +05:30
Dhruwang
62f19ba4d9 fix 2025-05-06 13:53:12 +05:30
Dhruwang
70aba27e82 add go in each stage 2025-05-06 13:50:06 +05:30
Dhruwang
e94cf10c36 removed gcc 2025-05-06 12:58:13 +05:30
Dhruwang
0f324c75ab removed superchronic 2025-05-06 12:26:20 +05:30
Dhruwang
4814f8821a fix 2025-05-06 12:21:01 +05:30
Dhruwang
b44df3b6e1 fix 2025-05-06 11:53:19 +05:30
Dhruwang
a626600786 fix 2025-05-06 11:34:16 +05:30
Dhruwang
6fc1f77845 commented add step 2025-05-06 11:19:50 +05:30
Dhruwang
defc5b29e1 added release version 2025-05-06 11:03:02 +05:30
Dhruwang
e6c741bd3b fix 2025-05-06 11:02:44 +05:30
Dhruwang
3207350bd5 fix 2025-05-05 18:17:29 +05:30
Dhruwang
bbe423319e fix 2025-05-05 16:58:37 +05:30
Dhruwang
40d8d86cd6 fix 2025-05-05 16:53:02 +05:30
Dhruwang
87934d9a68 fix 2025-05-05 16:48:54 +05:30
Dhruwang
0d19569936 fix 2025-05-05 16:44:32 +05:30
Dhruwang
d67dd965ab fix 2025-05-05 16:41:10 +05:30
Dhruwang
328e2db17f fixed zstd veraion 2025-05-05 16:36:24 +05:30
Dhruwang
46e5975653 fix build 2025-05-05 16:32:53 +05:30
Dhruwang
6145f11ddf fix build 2025-05-05 16:28:34 +05:30
Dhruwang
88cff4e52f adding missing package versions and removed edge repo 2025-05-05 16:25:50 +05:30
Dhruwang
801446bb86 Merge branch 'main' of https://github.com/formbricks/formbricks into docker-package-version-update 2025-05-05 16:14:26 +05:30
Dhruwang
bc5d048c39 fix 2025-04-30 19:29:10 +05:30
Dhruwang
f236047438 fix 2025-04-30 19:25:34 +05:30
Dhruwang
beb7ed0f3f fix redirect 2025-04-30 19:20:57 +05:30
Dhruwang
184bcd12c9 fix test 2025-04-30 19:17:15 +05:30
Dhruwang
a21911b777 sonarqube fixes 2025-04-30 19:15:24 +05:30
Dhruwang
c1df575b83 removed unrelated changes 2025-04-30 18:22:35 +05:30
Dhruwang
c6dba4454f fix 2025-04-30 18:16:39 +05:30
Dhruwang
81c7b54eae restored changes 2025-04-30 16:28:20 +05:30
Dhruwang
f0c2d75a4b fix 2025-04-30 16:07:21 +05:30
Dhruwang
44feb59cfc fix build 2025-04-30 16:03:51 +05:30
Dhruwang
3a4885c459 fix build 2025-04-30 16:00:54 +05:30
Dhruwang
6076ddd8c8 fix build 2025-04-30 15:58:12 +05:30
Dhruwang
f96530fef5 fix 2025-04-30 15:54:23 +05:30
Dhruwang
3c22bd3ccb fix build 2025-04-30 15:51:19 +05:30
Dhruwang
d05f5b26f8 added verification step 2025-04-30 15:41:47 +05:30
Dhruwang
3765e0da54 fix build 2025-04-30 14:43:47 +05:30
Dhruwang
9eea429b44 fix copy syntax 2025-04-30 14:40:42 +05:30
Dhruwang
a05a391080 fix location 2025-04-30 14:37:55 +05:30
Dhruwang
d10da85ac0 fix 2025-04-30 14:34:15 +05:30
Dhruwang
19ea25d483 fix build 2025-04-30 14:31:07 +05:30
Dhruwang
60e26a9ada fix build 2025-04-30 14:24:02 +05:30
Dhruwang
579351cdcd custom versions 2025-04-30 14:19:28 +05:30
Dhruwang
2dbc9559d5 fix build 2025-04-30 10:56:39 +05:30
Dhruwang
fdd84f84a5 fix build 2025-04-30 10:41:23 +05:30
Dhruwang
6bfc54b43c fix 2025-04-30 10:27:43 +05:30
Dhruwang
d18003507e updated openssl 2025-04-30 10:04:58 +05:30
Dhruwang
777485e63d fix alpine version 2025-04-30 09:43:06 +05:30
Dhruwang
0471a0f0c3 Merge branch 'main' of https://github.com/formbricks/formbricks into docker-package-version-update 2025-04-30 09:35:48 +05:30
Dhruwang
6290c6020d manual setup for libxml2 2025-04-30 09:35:43 +05:30
Dhruwang
304db65c66 fix 2025-04-25 11:00:57 +05:30
Dhruwang
1f979c91d3 fix 2025-04-25 10:55:57 +05:30
Dhruwang
3f532b859c added libxml2 version 2025-04-25 10:39:41 +05:30
Dhruwang
05043b1762 custom package versions 2025-04-25 10:22:56 +05:30
Dhruwang
6c724a0b1b updated base image 2025-04-24 17:18:31 +05:30
Dhruwang
f185ff85c5 updated alpine version 2025-04-24 17:09:28 +05:30
889 changed files with 8548 additions and 98805 deletions

View File

@@ -93,6 +93,10 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1
# Signup. Disable the ability for new users to create an account.
# Note: This variable is only available to the SaaS setup of Formbricks Cloud. Signup is disable by default for self-hosting.
# SIGNUP_DISABLED=1
# Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1
@@ -154,6 +158,10 @@ NOTION_OAUTH_CLIENT_SECRET=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=
GOOGLE_SHEETS_CLIENT_SECRET=
@@ -172,8 +180,8 @@ 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)
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=
# DEFAULT_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=owner
# Send new users to Brevo
# BREVO_API_KEY=
@@ -190,7 +198,8 @@ 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_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -198,6 +207,9 @@ UNSPLASH_ACCESS_KEY=
# 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=
@@ -212,4 +224,4 @@ UNKEY_ROOT_KEY=
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT=1
# DISABLE_USER_MANAGEMENT

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ jobs:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
description: 'The version of the Docker image to release'
required: true
type: string
REPOSITORY:
@@ -67,7 +67,7 @@ jobs:
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Prod
if: inputs.ENVIRONMENT == 'prod'
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -75,7 +75,6 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
@@ -85,14 +84,13 @@ jobs:
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: inputs.ENVIRONMENT == 'stage'
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets

View File

@@ -11,8 +11,6 @@ on:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
workflow_dispatch:
@@ -50,17 +48,15 @@ jobs:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: allow
allowed-endpoints: |
ee.formbricks.com:443
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
- name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 22.x
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
@@ -79,7 +75,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=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
shell: bash
@@ -93,18 +89,8 @@ 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

@@ -30,5 +30,5 @@ jobs:
- docker-build
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
VERSION: ${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"

27
.github/workflows/labeler.yml vendored Normal file
View File

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

View File

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

View File

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

5
apps/demo/.env.example Normal file
View File

@@ -0,0 +1,5 @@
NEXT_PUBLIC_FORMBRICKS_API_HOST=http://localhost:3000
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=YOUR_ENVIRONMENT_ID
# Copy the environment ID for the URL of your Formbricks App and
# paste it above to connect your Formbricks App with the Demo App.

7
apps/demo/.eslintrc.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
extends: ["@formbricks/eslint-config/next.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

36
apps/demo/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,13 @@
import { Sidebar } from "./sidebar";
export function LayoutApp({ children }: { children: React.ReactNode }): React.JSX.Element {
return (
<div className="min-h-full">
{/* Static sidebar for desktop */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<Sidebar />
</div>
<div className="flex flex-1 flex-col lg:pl-64">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import {
ClockIcon,
CogIcon,
CreditCardIcon,
FileBarChartIcon,
HelpCircleIcon,
HomeIcon,
ScaleIcon,
ShieldCheckIcon,
UsersIcon,
} from "lucide-react";
import { classNames } from "../lib/utils";
const navigation = [
{ name: "Home", href: "#", icon: HomeIcon, current: true },
{ name: "History", href: "#", icon: ClockIcon, current: false },
{ name: "Balances", href: "#", icon: ScaleIcon, current: false },
{ name: "Cards", href: "#", icon: CreditCardIcon, current: false },
{ name: "Recipients", href: "#", icon: UsersIcon, current: false },
{ name: "Reports", href: "#", icon: FileBarChartIcon, current: false },
];
const secondaryNavigation = [
{ name: "Settings", href: "#", icon: CogIcon },
{ name: "Help", href: "#", icon: HelpCircleIcon },
{ name: "Privacy", href: "#", icon: ShieldCheckIcon },
];
export function Sidebar(): React.JSX.Element {
return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
<div className="space-y-1 px-2">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
</div>
<div className="mt-6 pt-6">
<div className="space-y-1 px-2">
{secondaryNavigation.map((item) => (
<a
key={item.name}
href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}
</div>
</div>
</nav>
</div>
);
}

23
apps/demo/globals.css Normal file
View File

@@ -0,0 +1,23 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

3
apps/demo/lib/utils.ts Normal file
View File

@@ -0,0 +1,3 @@
export function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}

5
apps/demo/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

17
apps/demo/next.config.mjs Normal file
View File

@@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "tailwindui.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
export default nextConfig;

28
apps/demo/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@formbricks/demo",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
"dev": "next dev -p 3002 --turbopack",
"go": "next dev -p 3002 --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*"
}
}

20
apps/demo/pages/_app.tsx Normal file
View File

@@ -0,0 +1,20 @@
import type { AppProps } from "next/app";
import Head from "next/head";
import "../globals.css";
export default function App({ Component, pageProps }: AppProps): React.JSX.Element {
return (
<>
<Head>
<title>Demo App</title>
</Head>
{(!process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID ||
!process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) && (
<div className="w-full bg-red-500 p-3 text-center text-sm text-white">
Please set Formbricks environment variables in apps/demo/.env
</div>
)}
<Component {...pageProps} />
</>
);
}

View File

@@ -0,0 +1,13 @@
import { Head, Html, Main, NextScript } from "next/document";
export default function Document(): React.JSX.Element {
return (
<Html lang="en" className="h-full bg-slate-50">
<Head />
<body className="h-full">
<Main />
<NextScript />
</body>
</Html>
);
}

359
apps/demo/pages/index.tsx Normal file
View File

@@ -0,0 +1,359 @@
import Image from "next/image";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import formbricks from "@formbricks/js";
import fbsetup from "../public/fb-setup.png";
declare const window: Window;
export default function AppPage(): React.JSX.Element {
const [darkMode, setDarkMode] = useState(false);
const router = useRouter();
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userAttributes = {
"Attribute 1": "one",
"Attribute 2": "two",
"Attribute 3": "three",
};
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
useEffect(() => {
const initFormbricks = () => {
// enable Formbricks debug mode by adding formbricksDebug=true GET parameter
const addFormbricksDebugParam = (): void => {
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has("formbricksDebug")) {
urlParams.set("formbricksDebug", "true");
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
window.history.replaceState({}, "", newUrl);
}
};
addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
void formbricks.setup({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
});
}
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricks.registerRouteChange;
router.events.on("routeChangeComplete", () => {
void handleRouteChange();
});
return () => {
router.events.off("routeChangeComplete", () => {
void handleRouteChange();
});
};
}
};
initFormbricks();
}, [router.events]);
return (
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex flex-col items-center gap-2 sm:flex-row">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
</div>
<button
type="button"
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => {
setDarkMode(!darkMode);
}}>
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500" />
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold dark:text-white">
Set a user ID / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-slate-300">
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
the local state gets <strong>updated with the user state</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
type="button"
onClick={() => {
void formbricks.setUserId(userId);
}}>
Set user ID
</button>
<p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
No-Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
No Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttribute("Plan", "Free");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Free&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Free&apos;. If the attribute does not exist, it creates it.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttribute("Plan", "Paid");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Paid&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Paid&apos;. If the attribute does not exist, it creates it.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setEmail("test@web.com");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Email
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user email
</a>{" "}
&apos;test@web.com&apos;
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setAttributes(userAttributes);
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Multiple Attributes
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
user attributes
</a>{" "}
to &apos;one&apos;, &apos;two&apos;, &apos;three&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
onClick={() => {
void formbricks.setLanguage("de");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Language to &apos;de&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
target="_blank"
rel="noopener noreferrer"
className="underline dark:text-blue-500">
language
</a>{" "}
to &apos;de&apos;.
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.track("code");
}}>
Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends a{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
className="underline dark:text-blue-500"
target="_blank">
Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
rel="noopener noreferrer"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
</div>
</div>
<div className="p-6">
<div>
<button
type="button"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
void formbricks.logout();
}}>
Logout
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button logs out the user and syncs the local state with Formbricks. (Only works if a
userId is set)
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

5
apps/demo/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"exclude": ["node_modules"],
"extends": "@formbricks/config-typescript/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}

View File

@@ -11,12 +11,13 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.20",
"eslint-plugin-react-refresh": "0.4.19",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
@@ -26,13 +27,14 @@
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"vite": "6.3.5"
"tsup": "8.4.0",
"vite": "6.2.5"
}
}

View File

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

View File

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

View File

@@ -1,144 +0,0 @@
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import OnboardingLayout from "./layout";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
}));
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 new AuthorizationError("User is not authorized to access this environment");
throw AuthorizationError;
}
return <div className="flex-1 bg-slate-50">{children}</div>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
@@ -124,6 +125,7 @@ export const LandingSidebar = ({
<DropdownMenuItem
onClick={async () => {
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

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

View File

@@ -1,197 +0,0 @@
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound, redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
}));
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/license";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";

View File

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

View File

@@ -1,220 +0,0 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import OnboardingLayout from "./layout";
// Mock environment variables
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
// Mock dependencies
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getOrganizationProjectsCount: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
describe("OnboardingLayout", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects to login if no session", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("returns not found if user is member or billing", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "member",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(notFound).toHaveBeenCalled();
});
test("throws error if organization is not found", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getOrganization).mockResolvedValue(null);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
});
test("redirects to home if project limit is reached", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
};
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(redirect).toHaveBeenCalledWith("/");
});
test("renders children when all conditions are met", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
};
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
const result = await OnboardingLayout(props);
expect(result).toEqual(<>{props.children}</>);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
// Mock next/navigation hooks.
vi.mock("next/navigation", () => ({
usePathname: () => "/test-path",
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
}));
// Mock the Formbricks SDK module.
vi.mock("@formbricks/js", () => ({
__esModule: true,
default: {
setup: vi.fn(),
setUserId: vi.fn(),
setEmail: vi.fn(),
registerRouteChange: vi.fn(),
},
}));
describe("FormbricksClient", () => {
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(
<FormbricksClient
userId="user-123"
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
environmentId: "env-test",
appUrl: "https://api.test.com",
});
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
// And the second effect should always register the route change when Formbricks is enabled.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(
<FormbricksClient
userId=""
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();
expect(mockSetUserId).not.toHaveBeenCalled();
expect(mockSetEmail).not.toHaveBeenCalled();
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
expect(mockRegisterRouteChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,44 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js";
interface FormbricksClientProps {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (formbricksEnabled && userId) {
formbricks.setup({
environmentId: formbricksEnvironmentId ?? "",
appUrl: formbricksApiHost ?? "",
});
formbricks.setUserId(userId);
formbricks.setEmail(email);
}
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
useEffect(() => {
if (formbricksEnabled) {
formbricks.registerRouteChange();
}
}, [pathname, searchParams, formbricksEnabled]);
return null;
};

View File

@@ -1,34 +0,0 @@
import { SingleContactPage } from "@/modules/ee/contacts/[contactId]/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
// mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
ENCRYPTION_KEY: "test",
ENTERPRISE_LICENSE_KEY: "test",
GITHUB_ID: "test",
GITHUB_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
}));
describe("Contact Page Re-export", () => {
test("should re-export SingleContactPage", () => {
expect(Page).toBe(SingleContactPage);
});
});

View File

@@ -1,15 +0,0 @@
import { ContactsPage } from "@/modules/ee/contacts/page";
import { describe, expect, test, vi } from "vitest";
import Page from "./page";
// Mock the actual ContactsPage component
vi.mock("@/modules/ee/contacts/page", () => ({
ContactsPage: () => <div data-testid="contacts-page">Mock Contacts Page</div>,
}));
describe("Contacts Page Re-export", () => {
test("should re-export ContactsPage from the EE module", () => {
// Assert that the default export 'Page' is the same as the mocked 'ContactsPage'
expect(Page).toBe(ContactsPage);
});
});

View File

@@ -1,18 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import SegmentsPageWrapper from "./page";
vi.mock("@/modules/ee/contacts/segments/page", () => ({
SegmentsPage: vi.fn(() => <div>SegmentsPageMock</div>),
}));
describe("SegmentsPageWrapper", () => {
afterEach(() => {
cleanup();
});
test("renders the SegmentsPage component", () => {
render(<SegmentsPageWrapper params={{ environmentId: "test-env" } as any} />);
expect(screen.getByText("SegmentsPageMock")).toBeInTheDocument();
});
});

View File

@@ -1,343 +0,0 @@
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "../actions";
import { ActionActivityTab } from "./ActionActivityTab";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
ACTION_TYPE_ICON_LOOKUP: {
noCode: <div>NoCodeIcon</div>,
automatic: <div>AutomaticIcon</div>,
code: <div>CodeIcon</div>,
},
}));
vi.mock("@/lib/time", () => ({
convertDateTimeStringShort: (dateString: string) => `formatted-${dateString}`,
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
}));
vi.mock("@/lib/utils/strings", () => ({
capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
}));
vi.mock("@/modules/survey/editor/actions", () => ({
createActionClassAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, ...props }: any) => (
<button onClick={onClick} data-variant={variant} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/error-component", () => ({
ErrorComponent: () => <div>ErrorComponent</div>,
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children, ...props }: any) => <label {...props}>{children}</label>,
}));
vi.mock("@/modules/ui/components/loading-spinner", () => ({
LoadingSpinner: () => <div>LoadingSpinner</div>,
}));
vi.mock("../actions", () => ({
getActiveInactiveSurveysAction: vi.fn(),
}));
const mockActionClass = {
id: "action1",
createdAt: new Date("2023-01-01T10:00:00Z"),
updatedAt: new Date("2023-01-10T11:00:00Z"),
name: "Test Action",
description: "Test Description",
type: "noCode",
environmentId: "env1_dev",
noCodeConfig: {
/* ... */
} as any,
} as unknown as TActionClass;
const mockEnvironmentDev = {
id: "env1_dev",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
} as unknown as TEnvironment;
const mockEnvironmentProd = {
id: "env1_prod",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
} as unknown as TEnvironment;
const mockOtherEnvActionClasses: TActionClass[] = [
{
id: "action2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Existing Action Prod",
type: "noCode",
environmentId: "env1_prod",
} as unknown as TActionClass,
{
id: "action3",
createdAt: new Date(),
updatedAt: new Date(),
name: "Existing Code Action Prod",
type: "code",
key: "existing-key",
environmentId: "env1_prod",
} as unknown as TActionClass,
];
describe("ActionActivityTab", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
data: {
activeSurveys: ["Active Survey 1"],
inactiveSurveys: ["Inactive Survey 1", "Inactive Survey 2"],
},
});
});
afterEach(() => {
cleanup();
});
test("renders loading state initially", () => {
// Don't resolve the promise immediately
vi.mocked(getActiveInactiveSurveysAction).mockReturnValue(new Promise(() => {}));
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
expect(screen.getByText("LoadingSpinner")).toBeInTheDocument();
});
test("renders error state if fetching surveys fails", async () => {
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
data: undefined,
});
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
// Wait for the component to update after the promise resolves
await screen.findByText("ErrorComponent");
expect(screen.getByText("ErrorComponent")).toBeInTheDocument();
});
test("renders survey lists and action details correctly", async () => {
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
// Wait for loading to finish
await screen.findByText("common.active_surveys");
// Check survey lists
expect(screen.getByText("Active Survey 1")).toBeInTheDocument();
expect(screen.getByText("Inactive Survey 1")).toBeInTheDocument();
expect(screen.getByText("Inactive Survey 2")).toBeInTheDocument();
// Check action details
// Use the actual Date.toString() output that the mock receives
expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
});
test("calls copyAction with correct data on button click", async () => {
vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).toHaveBeenCalledTimes(1);
// Include the extra properties that the component sends due to spreading mockActionClass
const expectedActionInput = {
...mockActionClass, // Spread the original object
name: "Test Action", // Keep the original name as it doesn't conflict
environmentId: "env1_prod", // Target environment ID
};
// Remove properties not expected by the action call itself, even if sent by component
delete (expectedActionInput as any).id;
delete (expectedActionInput as any).createdAt;
delete (expectedActionInput as any).updatedAt;
// The assertion now checks against the structure sent by the component
expect(createActionClassAction).toHaveBeenCalledWith({
action: {
...mockActionClass, // Include id, createdAt, updatedAt etc.
name: "Test Action",
environmentId: "env1_prod",
},
});
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
});
test("handles name conflict during copy", async () => {
vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
const conflictingActionClass = { ...mockActionClass, name: "Existing Action Prod" };
render(
<ActionActivityTab
actionClass={conflictingActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).toHaveBeenCalledTimes(1);
// The assertion now checks against the structure sent by the component
expect(createActionClassAction).toHaveBeenCalledWith({
action: {
...conflictingActionClass, // Include id, createdAt, updatedAt etc.
name: "Existing Action Prod (copy)",
environmentId: "env1_prod",
},
});
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
});
test("handles key conflict during copy for 'code' type", async () => {
const codeActionClass: TActionClass = {
...mockActionClass,
id: "codeAction1",
type: "code",
key: "existing-key", // Conflicting key
noCodeConfig: {
/* ... */
} as any,
};
render(
<ActionActivityTab
actionClass={codeActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.actions.action_with_key_already_exists");
});
test("shows error if copy action fails server-side", async () => {
vi.mocked(createActionClassAction).mockResolvedValue({ data: undefined });
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={false}
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).toHaveBeenCalledTimes(1);
expect(toast.error).toHaveBeenCalledWith("environments.actions.action_copy_failed");
});
test("shows error and prevents copy if user is read-only", async () => {
render(
<ActionActivityTab
actionClass={mockActionClass}
environmentId="env1_dev"
environment={mockEnvironmentDev}
otherEnvActionClasses={mockOtherEnvActionClasses}
otherEnvironment={mockEnvironmentProd}
isReadOnly={true} // Set to read-only
/>
);
await screen.findByText("Copy to Production");
const copyButton = screen.getByText("Copy to Production");
await userEvent.click(copyButton);
expect(createActionClassAction).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("common.you_are_not_authorised_to_perform_this_action");
});
test("renders correct copy button text for production environment", async () => {
render(
<ActionActivityTab
actionClass={{ ...mockActionClass, environmentId: "env1_prod" }}
environmentId="env1_prod"
environment={mockEnvironmentProd} // Current env is Production
otherEnvActionClasses={[]} // Assume dev env has no actions for simplicity
otherEnvironment={mockEnvironmentDev} // Target env is Development
isReadOnly={false}
/>
);
await screen.findByText("Copy to Development");
expect(screen.getByText("Copy to Development")).toBeInTheDocument();
expect(screen.getByText("Production")).toBeInTheDocument(); // Environment text
});
});

View File

@@ -1,122 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { ActionClassesTable } from "./ActionClassesTable";
// Mock the ActionDetailModal
vi.mock("./ActionDetailModal", () => ({
ActionDetailModal: ({ open, actionClass, setOpen }: any) =>
open ? (
<div data-testid="action-detail-modal">
Modal for {actionClass.name}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
}));
const mockActionClasses: TActionClass[] = [
{ id: "1", name: "Action 1", type: "noCode", environmentId: "env1" } as TActionClass,
{ id: "2", name: "Action 2", type: "code", environmentId: "env1" } as TActionClass,
];
const mockEnvironment: TEnvironment = {
id: "env1",
name: "Test Environment",
type: "development",
} as unknown as TEnvironment;
const mockOtherEnvironment: TEnvironment = {
id: "env2",
name: "Other Environment",
type: "production",
} as unknown as TEnvironment;
const mockTableHeading = <div data-testid="table-heading">Table Heading</div>;
const mockActionRows = mockActionClasses.map((action) => (
<div key={action.id} data-testid={`action-row-${action.id}`}>
{action.name} Row
</div>
));
describe("ActionClassesTable", () => {
afterEach(() => {
cleanup();
});
test("renders table heading and action rows when actions exist", () => {
render(
<ActionClassesTable
environmentId="env1"
actionClasses={mockActionClasses}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, mockActionRows]}
</ActionClassesTable>
);
expect(screen.getByTestId("table-heading")).toBeInTheDocument();
expect(screen.getByTestId("action-row-1")).toBeInTheDocument();
expect(screen.getByTestId("action-row-2")).toBeInTheDocument();
expect(screen.queryByText("No actions found")).not.toBeInTheDocument();
});
test("renders 'No actions found' message when no actions exist", () => {
render(
<ActionClassesTable
environmentId="env1"
actionClasses={[]}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, []]}
</ActionClassesTable>
);
expect(screen.getByTestId("table-heading")).toBeInTheDocument();
expect(screen.getByText("No actions found")).toBeInTheDocument();
expect(screen.queryByTestId("action-row-1")).not.toBeInTheDocument();
});
test("opens ActionDetailModal with correct action when a row is clicked", async () => {
render(
<ActionClassesTable
environmentId="env1"
actionClasses={mockActionClasses}
environment={mockEnvironment}
isReadOnly={false}
otherEnvActionClasses={[]}
otherEnvironment={mockOtherEnvironment}>
{[mockTableHeading, mockActionRows]}
</ActionClassesTable>
);
// Modal should not be open initially
expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
// Find the button wrapping the first action row
const actionButton1 = screen.getByTitle("Action 1");
await userEvent.click(actionButton1);
// Modal should now be open with the correct action name
const modal = screen.getByTestId("action-detail-modal");
expect(modal).toBeInTheDocument();
expect(modal).toHaveTextContent("Modal for Action 1");
// Close the modal
await userEvent.click(screen.getByText("Close Modal"));
expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
// Click the second action button
const actionButton2 = screen.getByTitle("Action 2");
await userEvent.click(actionButton2);
// Modal should open for the second action
const modal2 = screen.getByTestId("action-detail-modal");
expect(modal2).toBeInTheDocument();
expect(modal2).toHaveTextContent("Modal for Action 2");
});
});

View File

@@ -1,180 +0,0 @@
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { ActionActivityTab } from "./ActionActivityTab";
import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock child components
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
<div data-testid="modal-with-tabs">
<span data-testid="modal-label">{label}</span>
<span data-testid="modal-description">{description}</span>
<span data-testid="modal-open">{open.toString()}</span>
<button onClick={() => setOpen(false)}>Close</button>
{icon}
{tabs.map((tab) => (
<div key={tab.title}>
<h2>{tab.title}</h2>
{tab.children}
</div>
))}
</div>
)),
}));
vi.mock("./ActionActivityTab", () => ({
ActionActivityTab: vi.fn(() => <div data-testid="action-activity-tab">ActionActivityTab</div>),
}));
vi.mock("./ActionSettingsTab", () => ({
ActionSettingsTab: vi.fn(() => <div data-testid="action-settings-tab">ActionSettingsTab</div>),
}));
// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
ACTION_TYPE_ICON_LOOKUP: {
code: <div data-testid="code-icon">Code Icon Mock</div>,
noCode: <div data-testid="nocode-icon">No Code Icon Mock</div>,
// Add other types if needed by other tests or default props
},
}));
const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn();
const mockEnvironment = {
id: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "production", // Use string literal as TEnvironmentType is not exported
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockActionClass: TActionClass = {
id: "action-class-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action",
description: "This is a test action",
type: "code", // Ensure this matches a key in the mocked ACTION_TYPE_ICON_LOOKUP
environmentId: mockEnvironmentId,
noCodeConfig: null,
key: "test-action-key",
};
const mockActionClasses: TActionClass[] = [mockActionClass];
const mockOtherEnvActionClasses: TActionClass[] = [];
const mockOtherEnvironment = { ...mockEnvironment, id: "other-env-id", name: "Other Environment" };
const defaultProps = {
environmentId: mockEnvironmentId,
environment: mockEnvironment,
open: true,
setOpen: mockSetOpen,
actionClass: mockActionClass,
actionClasses: mockActionClasses,
isReadOnly: false,
otherEnvironment: mockOtherEnvironment,
otherEnvActionClasses: mockOtherEnvActionClasses,
};
describe("ActionDetailModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks(); // Clear mocks after each test
});
test("renders ModalWithTabs with correct props", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
expect(mockedModalWithTabs).toHaveBeenCalled();
const props = mockedModalWithTabs.mock.calls[0][0];
// Check basic props
expect(props.open).toBe(true);
expect(props.setOpen).toBe(mockSetOpen);
expect(props.label).toBe(mockActionClass.name);
expect(props.description).toBe(mockActionClass.description);
// Check icon data-testid based on the mock for the default 'code' type
expect(props.icon).toBeDefined();
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Check tabs structure
expect(props.tabs).toHaveLength(2);
expect(props.tabs[0].title).toBe("common.activity");
expect(props.tabs[1].title).toBe("common.settings");
// Check if the correct mocked components are used as children
// Access the mocked functions directly
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
// Check props passed to child components
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
expect(activityTabProps.isReadOnly).toBe(false);
expect(activityTabProps.environment).toBe(mockEnvironment);
expect(activityTabProps.actionClass).toBe(mockActionClass);
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.actionClass).toBe(mockActionClass);
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
expect(settingsTabProps.isReadOnly).toBe(false);
});
test("renders correct icon based on action type", () => {
// Test with 'noCode' type
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
// Expect the 'nocode-icon' based on the updated mock and action type
expect(props.icon).toBeDefined();
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
});
test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// Access the mocked component directly
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.isReadOnly).toBe(true);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
});
});

View File

@@ -1,63 +0,0 @@
import { timeSince } from "@/lib/time";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { ActionClassDataRow } from "./ActionRowData";
vi.mock("@/lib/time", () => ({
timeSince: vi.fn(),
}));
const mockActionClass: TActionClass = {
id: "testId",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action",
description: "This is a test action",
type: "code",
noCodeConfig: null,
environmentId: "envId",
key: null,
};
const locale = "en-US";
const timeSinceOutput = "2 hours ago";
describe("ActionClassDataRow", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders code action correctly", () => {
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
const actionClass = { ...mockActionClass, type: "code" } as TActionClass;
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
});
test("renders no-code action correctly", () => {
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
const actionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
});
test("renders without description", () => {
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
const actionClass = { ...mockActionClass, description: undefined } as unknown as TActionClass;
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
expect(screen.queryByText("This is a test action")).not.toBeInTheDocument();
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
});
});

View File

@@ -1,265 +0,0 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass, TActionClassNoCodeConfig, TActionClassType } from "@formbricks/types/action-classes";
import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock actions
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
deleteActionClassAction: vi.fn(),
updateActionClassAction: vi.fn(),
}));
// Mock utils
vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
}));
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, loading, ...props }: any) => (
<button onClick={onClick} data-variant={variant} disabled={loading} {...props}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}>
Code Action Form
</div>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<span>Delete Dialog</span>
<button onClick={onDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Confirm Delete"}
</button>
<button onClick={() => setOpen(false)}>Cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
No Code Action Form
</div>
),
}));
// Mock icons
vi.mock("lucide-react", () => ({
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
}));
const mockSetOpen = vi.fn();
const mockActionClasses: TActionClass[] = [
{
id: "action1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Existing Action",
description: "An existing action",
type: "noCode",
environmentId: "env1",
noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
} as unknown as TActionClass,
];
const createMockActionClass = (id: string, type: TActionClassType, name: string): TActionClass =>
({
id,
createdAt: new Date(),
updatedAt: new Date(),
name,
description: `${name} description`,
type,
environmentId: "env1",
...(type === "code" && { key: `${name}-key` }),
...(type === "noCode" && {
noCodeConfig: { type: "url", rule: "exactMatch", value: `http://${name}.com` },
}),
}) as unknown as TActionClass;
describe("ActionSettingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders correctly for 'code' action type", () => {
const actionClass = createMockActionClass("code1", "code", "Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
expect(
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
});
test("renders correctly for 'noCode' action type", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
});
test("handles successful deletion", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
vi.mocked(deleteActionClassAction).mockResolvedValue({ data: actionClass } as any);
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
await userEvent.click(deleteButtonTrigger);
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(deleteActionClassAction).toHaveBeenCalledWith({ actionClassId: actionClass.id });
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_deleted_successfully");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("handles deletion failure", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
vi.mocked(deleteActionClassAction).mockRejectedValue(new Error("Deletion failed"));
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
await userEvent.click(deleteButtonTrigger);
const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(deleteActionClassAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("renders read-only state correctly", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true} // Set to read-only
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
});
test("prevents delete when read-only", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
// Render with isReadOnly=true, but simulate a delete attempt
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true}
/>
);
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
// This test primarily checks the logic within handleDeleteAction if it were called.
// A better approach might be to export handleDeleteAction for direct testing,
// but for now, we assume the UI prevents calling it.
// We can assert that the delete button isn't there to prevent the flow
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(deleteActionClassAction).not.toHaveBeenCalled();
});
test("renders docs link correctly", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const docsLink = screen.getByRole("link", { name: "common.read_docs" });
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
expect(docsLink).toHaveAttribute("target", "_blank");
});
});

View File

@@ -1,26 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ActionTableHeading } from "./ActionTableHeading";
// Mock the server-side translation function
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
describe("ActionTableHeading", () => {
afterEach(() => {
cleanup();
});
test("renders the table heading with correct column names", async () => {
// Render the async component
const ResolvedComponent = await ActionTableHeading();
render(ResolvedComponent);
// Check if the translated column headers are present
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
expect(screen.getByText("common.created")).toBeInTheDocument();
// Check for the screen reader only text
expect(screen.getByText("common.edit")).toBeInTheDocument();
});
});

View File

@@ -1,142 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { AddActionModal } from "./AddActionModal";
// Mock child components and hooks
vi.mock("@/modules/survey/editor/components/create-new-action-tab", () => ({
CreateNewActionTab: vi.fn(({ setOpen }) => (
<div data-testid="create-new-action-tab">
<span>CreateNewActionTab Content</span>
<button onClick={() => setOpen(false)}>Close from Tab</button>
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, ...props }: any) =>
open ? (
<div data-testid="modal" {...props}>
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("lucide-react", () => ({
MousePointerClickIcon: () => <div data-testid="mouse-pointer-icon" />,
PlusIcon: () => <div data-testid="plus-icon" />,
}));
const mockActionClasses: TActionClass[] = [
{
id: "action1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 1",
description: "Description 1",
type: "noCode",
environmentId: "env1",
noCodeConfig: { type: "click" } as unknown as TActionClassNoCodeConfig,
} as unknown as TActionClass,
];
const environmentId = "env1";
describe("AddActionModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the 'Add Action' button initially", () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("opens the modal when the 'Add Action' button is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect(
screen.getByText("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")
).toBeInTheDocument();
expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
});
test("passes correct props to CreateNewActionTab", async () => {
const { CreateNewActionTab } = await import("@/modules/survey/editor/components/create-new-action-tab");
const mockedCreateNewActionTab = vi.mocked(CreateNewActionTab);
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(mockedCreateNewActionTab).toHaveBeenCalled();
const props = mockedCreateNewActionTab.mock.calls[0][0];
expect(props.environmentId).toBe(environmentId);
expect(props.actionClasses).toEqual(mockActionClasses); // Initial state check
expect(props.isReadOnly).toBe(false);
expect(props.setOpen).toBeInstanceOf(Function);
expect(props.setActionClasses).toBeInstanceOf(Function);
});
test("closes the modal when the close button (simulated) is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
// Simulate closing via the mocked Modal's close button
const closeModalButton = screen.getByText("Close Modal");
await userEvent.click(closeModalButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
});

View File

@@ -1,44 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock child components
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="page-content-wrapper">{children}</div>
),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
}));
describe("Loading", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
// Check if mocked components are rendered
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions");
// Check for translated table headers
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
expect(screen.getByText("common.created")).toBeInTheDocument();
expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text
// Check for skeleton elements (presence of animate-pulse class)
const skeletonElements = document.querySelectorAll(".animate-pulse");
expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered
// Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12)
const pulseDivs = screen.getAllByText((_, element) => {
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
});
expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created)
});
});

View File

@@ -1,161 +0,0 @@
import { getActionClasses } from "@/lib/actionClass/service";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
// Import the component after mocks
import Page from "./page";
// Mock dependencies
vi.mock("@/lib/actionClass/service", () => ({
getActionClasses: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironments: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({
ActionClassesTable: ({ children }) => <div>ActionClassesTable Mock{children}</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
ActionClassDataRow: ({ actionClass }) => <div>ActionClassDataRow Mock: {actionClass.name}</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
ActionTableHeading: () => <div>ActionTableHeading Mock</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
AddActionModal: () => <div>AddActionModal Mock</div>,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>PageContentWrapper Mock{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle, cta }) => (
<div>
PageHeader Mock: {pageTitle} {cta && <div>CTA Mock</div>}
</div>
),
}));
// Mock data
const mockEnvironmentId = "test-env-id";
const mockProjectId = "test-project-id";
const mockEnvironment = {
id: mockEnvironmentId,
name: "Test Environment",
type: "development",
} as unknown as TEnvironment;
const mockOtherEnvironment = {
id: "other-env-id",
name: "Other Environment",
type: "production",
} as unknown as TEnvironment;
const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject;
const mockActionClasses = [
{ id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass,
{ id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass,
];
const mockOtherEnvActionClasses = [
{ id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass,
];
const mockLocale = "en-US";
const mockParams = { environmentId: mockEnvironmentId };
const mockProps = { params: mockParams };
describe("Actions Page", () => {
beforeEach(() => {
vi.mocked(getActionClasses)
.mockResolvedValueOnce(mockActionClasses) // First call for current env
.mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("renders the page correctly with actions", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument();
expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument();
expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument();
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("redirects if isBilling is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: true,
environment: mockEnvironment,
} as TEnvironmentAuth);
await Page(mockProps);
expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
});
test("does not render AddActionModal CTA if isReadOnly is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: true,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
});
test("renders AddActionModal CTA if isReadOnly is false", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
isReadOnly: false,
project: mockProject,
isBilling: false,
environment: mockEnvironment,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
});
});

View File

@@ -1,39 +0,0 @@
import { cleanup, render } from "@testing-library/react";
import { Code2Icon, MousePointerClickIcon } from "lucide-react";
import React from "react";
import { afterEach, describe, expect, test } from "vitest";
import { ACTION_TYPE_ICON_LOOKUP } from "./utils";
describe("ACTION_TYPE_ICON_LOOKUP", () => {
afterEach(() => {
cleanup();
});
test("should contain the correct icon for 'code'", () => {
expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("code");
const IconComponent = ACTION_TYPE_ICON_LOOKUP.code;
expect(React.isValidElement(IconComponent)).toBe(true);
// Render the icon and check if it's the correct Lucide icon
const { container } = render(IconComponent);
const svgElement = container.querySelector("svg");
expect(svgElement).toBeInTheDocument();
// Check for a class or attribute specific to Code2Icon if possible,
// or compare the rendered output structure if necessary.
// For simplicity, we check the component type directly (though this is less robust)
expect(IconComponent.type).toBe(Code2Icon);
});
test("should contain the correct icon for 'noCode'", () => {
expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("noCode");
const IconComponent = ACTION_TYPE_ICON_LOOKUP.noCode;
expect(React.isValidElement(IconComponent)).toBe(true);
// Render the icon and check if it's the correct Lucide icon
const { container } = render(IconComponent);
const svgElement = container.querySelector("svg");
expect(svgElement).toBeInTheDocument();
// Similar check as above for MousePointerClickIcon
expect(IconComponent.type).toBe(MousePointerClickIcon);
});
});

View File

@@ -1,391 +0,0 @@
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
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";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembership } from "@formbricks/types/memberships";
import {
TOrganization,
TOrganizationBilling,
TOrganizationBillingPlanLimits,
} from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
// Mock services and utils
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
getEnvironments: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
getOrganizationsByUserId: vi.fn(),
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
getMonthlyOrganizationResponseCount: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getUserProjects: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
let mockIsFormbricksCloud = false;
let mockIsDevelopment = false;
vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
get IS_DEVELOPMENT() {
return mockIsDevelopment;
},
}));
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
}));
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
}));
vi.mock("@/modules/ui/components/pending-downgrade-banner", () => ({
PendingDowngradeBanner: ({
isPendingDowngrade,
active,
}: {
isPendingDowngrade: boolean;
active: boolean;
}) =>
isPendingDowngrade && active ? <div data-testid="downgrade-banner">PendingDowngradeBanner</div> : null,
}));
const mockUser = {
id: "user-1",
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
} as unknown as TUser;
const mockOrganization = {
id: "org-1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
} as unknown as TOrganizationBilling,
} as unknown as TOrganization;
const mockEnvironment: TEnvironment = {
id: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj-1",
appSetupCompleted: true,
};
const mockProject: TProject = {
id: "proj-1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org-1",
environments: [mockEnvironment],
} as unknown as TProject;
const mockMembership: TMembership = {
organizationId: "org-1",
userId: "user-1",
accepted: true,
role: "owner",
};
const mockLicense = {
plan: "free",
active: false,
lastChecked: new Date(),
features: { isMultiOrgEnabled: false },
} as any;
const mockProjectPermission = {
userId: "user-1",
projectId: "proj-1",
role: "admin",
} as any;
const mockSession: Session = {
user: {
id: "user-1",
},
expires: new Date(Date.now() + 3600 * 1000).toISOString(),
};
describe("EnvironmentLayout", () => {
beforeEach(() => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("renders correctly with default props", 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 { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
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();
});
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",
session: mockSession,
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",
session: mockSession,
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 () => {
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
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",
session: mockSession,
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"
);
});
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"
);
});
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);
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"
);
});
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,8 +12,7 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";

View File

@@ -1,33 +0,0 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import EnvironmentStorageHandler from "./EnvironmentStorageHandler";
describe("EnvironmentStorageHandler", () => {
test("sets environmentId in localStorage on mount", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const testEnvironmentId = "test-env-123";
render(<EnvironmentStorageHandler environmentId={testEnvironmentId} />);
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, testEnvironmentId);
setItemSpy.mockRestore();
});
test("updates environmentId in localStorage when prop changes", () => {
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const initialEnvironmentId = "test-env-initial";
const updatedEnvironmentId = "test-env-updated";
const { rerender } = render(<EnvironmentStorageHandler environmentId={initialEnvironmentId} />);
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, initialEnvironmentId);
rerender(<EnvironmentStorageHandler environmentId={updatedEnvironmentId} />);
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, updatedEnvironmentId);
expect(setItemSpy).toHaveBeenCalledTimes(2); // Called on mount and on rerender with new prop
setItemSpy.mockRestore();
});
});

View File

@@ -1,149 +0,0 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { EnvironmentSwitch } from "./EnvironmentSwitch";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({
push: mockPush,
})),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
const mockEnvironmentDev: TEnvironment = {
id: "dev-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "project-id",
appSetupCompleted: true,
};
const mockEnvironmentProd: TEnvironment = {
id: "prod-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "project-id",
appSetupCompleted: true,
};
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
describe("EnvironmentSwitch", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders checked when environment is development", () => {
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
const switchElement = screen.getByRole("switch");
expect(switchElement).toBeChecked();
expect(screen.getByText("common.dev_env")).toHaveClass("text-orange-800");
});
test("renders unchecked when environment is production", () => {
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
const switchElement = screen.getByRole("switch");
expect(switchElement).not.toBeChecked();
expect(screen.getByText("common.dev_env")).not.toHaveClass("text-orange-800");
});
test("calls router.push with development environment ID when toggled from production", async () => {
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
const switchElement = screen.getByRole("switch");
expect(switchElement).not.toBeChecked();
await userEvent.click(switchElement);
// Check loading state (switch disabled)
expect(switchElement).toBeDisabled();
// Check router push call
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
});
// Check visual state change (though state update happens before navigation)
// In a real scenario, the component would re-render with the new environment prop after navigation.
// Here, we simulate the state change directly for testing the toggle logic.
await waitFor(() => {
// Re-render or check internal state if possible, otherwise check mock calls
// Since the component manages its own state, we can check the visual state after click
expect(switchElement).toBeChecked(); // State updates immediately
});
});
test("calls router.push with production environment ID when toggled from development", async () => {
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
const switchElement = screen.getByRole("switch");
expect(switchElement).toBeChecked();
await userEvent.click(switchElement);
// Check loading state (switch disabled)
expect(switchElement).toBeDisabled();
// Check router push call
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentProd.id}/`);
});
// Check visual state change
await waitFor(() => {
expect(switchElement).not.toBeChecked(); // State updates immediately
});
});
test("does not call router.push if target environment is not found", async () => {
const incompleteEnvironments = [mockEnvironmentProd]; // Only production exists
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={incompleteEnvironments} />);
const switchElement = screen.getByRole("switch");
await userEvent.click(switchElement); // Try to toggle to development
await waitFor(() => {
expect(switchElement).toBeDisabled(); // Loading state still set
});
// router.push should not be called because dev env is missing
expect(mockPush).not.toHaveBeenCalled();
// State still updates visually
await waitFor(() => {
expect(switchElement).toBeChecked();
});
});
test("toggles using the label click", async () => {
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
const labelElement = screen.getByText("common.dev_env");
const switchElement = screen.getByRole("switch");
expect(switchElement).not.toBeChecked();
await userEvent.click(labelElement); // Click the label
// Check loading state (switch disabled)
expect(switchElement).toBeDisabled();
// Check router push call
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
});
// Check visual state change
await waitFor(() => {
expect(switchElement).toBeChecked();
});
});
});

View File

@@ -1,311 +0,0 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import { getLatestStableFbReleaseAction } from "../actions/actions";
import { MainNavigation } from "./MainNavigation";
// Mock dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
usePathname: vi.fn(() => "/environments/env1/surveys"),
}));
vi.mock("next-auth/react", () => ({
signOut: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
getLatestStableFbReleaseAction: vi.fn(),
}));
vi.mock("@/app/lib/formbricks", () => ({
formbricksLogout: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: (role?: string) => ({
isAdmin: role === "admin",
isOwner: role === "owner",
isManager: role === "manager",
isMember: role === "member",
isBilling: role === "billing",
}),
}));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) =>
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
}));
vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
</div>
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props: any) => <img alt="test" {...props} />,
}));
vi.mock("../../../../../package.json", () => ({
version: "1.0.0",
}));
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// Mock data
const mockEnvironment: TEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj1",
appSetupCompleted: true,
};
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(),
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;
const mockOrganization = {
id: "org1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: { stripeCustomerId: null, plan: "free", limits: { monthly: { responses: null } } } as any,
} as unknown as TOrganization;
const mockOrganizations: TOrganization[] = [
mockOrganization,
{ ...mockOrganization, id: "org2", name: "Another Org" },
];
const mockProject: TProject = {
id: "proj1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
environments: [mockEnvironment],
config: { channel: "website" },
} as unknown as TProject;
const mockProjects: TProject[] = [mockProject];
const defaultProps = {
environment: mockEnvironment,
organizations: mockOrganizations,
user: mockUser,
organization: mockOrganization,
projects: mockProjects,
isMultiOrgEnabled: true,
isFormbricksCloud: false,
isDevelopment: false,
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
};
describe("MainNavigation", () => {
let mockRouterPush: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockRouterPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any);
vi.mocked(usePathname).mockReturnValue("/environments/env1/surveys");
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null }); // Default: no new version
localStorage.clear();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders expanded by default and collapses on toggle", async () => {
render(<MainNavigation {...defaultProps} />);
const projectSwitcher = screen.getByTestId("project-switcher");
// Assuming the toggle button is the only one initially without an accessible name
// A more specific selector like data-testid would be better if available.
const toggleButton = screen.getByRole("button", { name: "" });
// Check initial state (expanded)
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
// Check localStorage is not set initially after clear()
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
// Click to collapse
await userEvent.click(toggleButton);
// Check state after first toggle (collapsed)
await waitFor(() => {
// Check that the attribute eventually becomes true
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
// Check that localStorage is updated
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
});
// Check that the logo is eventually hidden
await waitFor(() => {
expect(screen.queryByAltText("environments.formbricks_logo")).not.toBeInTheDocument();
});
// Click to expand
await userEvent.click(toggleButton);
// Check state after second toggle (expanded)
await waitFor(() => {
// Check that the attribute eventually becomes false
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
// Check that localStorage is updated
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
});
// Check that the logo is eventually visible
await waitFor(() => {
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
});
});
test("renders correct active navigation link", () => {
vi.mocked(usePathname).mockReturnValue("/environments/env1/actions");
render(<MainNavigation {...defaultProps} />);
const actionsLink = screen.getByRole("link", { name: /common.actions/ });
// Check if the parent li has the active class styling
expect(actionsLink.closest("li")).toHaveClass("border-brand-dark");
});
test("renders user dropdown and handles logout", async () => {
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
await userEvent.click(userTrigger);
// Wait for the dropdown content to appear
await waitFor(() => {
expect(screen.getByText("common.account")).toBeInTheDocument();
});
expect(screen.getByText("common.organization")).toBeInTheDocument();
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
expect(screen.getByText("common.documentation")).toBeInTheDocument();
expect(screen.getByText("common.logout")).toBeInTheDocument();
const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton);
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});
});
test("handles organization switching", async () => {
render(<MainNavigation {...defaultProps} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for the initial dropdown items
await waitFor(() => {
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
});
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
await userEvent.click(org2Item);
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
});
test("opens create organization modal", async () => {
render(<MainNavigation {...defaultProps} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for the initial dropdown items
await waitFor(() => {
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
});
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
await userEvent.click(createOrgButton);
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
});
test("hides new version banner for members or if no new version", async () => {
// Test for member
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
render(<MainNavigation {...defaultProps} membershipRole="member" />);
let toggleButton = screen.getByRole("button", { name: "" });
await userEvent.click(toggleButton);
await waitFor(() => {
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
});
cleanup(); // Clean up before next render
// Test for no new version
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null });
render(<MainNavigation {...defaultProps} membershipRole="owner" />);
toggleButton = screen.getByRole("button", { name: "" });
await userEvent.click(toggleButton);
await waitFor(() => {
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
});
});
test("hides main nav and project switcher if user role is billing", () => {
render(<MainNavigation {...defaultProps} membershipRole="billing" />);
expect(screen.queryByRole("link", { name: /common.surveys/ })).not.toBeInTheDocument();
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
});
test("shows billing link and hides license link in cloud", async () => {
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for dropdown items
await waitFor(() => {
expect(screen.getByText("common.billing")).toBeInTheDocument();
});
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
});

View File

@@ -2,6 +2,7 @@
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { formbricksLogout } from "@/app/lib/formbricks";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -109,7 +110,7 @@ export const MainNavigation = ({
useEffect(() => {
const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed);
setIsTextVisible(isCollapsed ? true : false);
};
const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId);
@@ -170,7 +171,7 @@ export const MainNavigation = ({
name: t("common.actions"),
href: `/environments/${environment.id}/actions`,
icon: MousePointerClick,
isActive: pathname?.includes("/actions"),
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
},
{
name: t("common.integrations"),
@@ -264,7 +265,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -391,6 +392,7 @@ export const MainNavigation = ({
onClick={async () => {
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
await formbricksLogout();
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -1,21 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { NavbarLoading } from "./NavbarLoading";
describe("NavbarLoading", () => {
afterEach(() => {
cleanup();
});
test("renders the correct number of skeleton elements", () => {
render(<NavbarLoading />);
// Find all divs with the animate-pulse class
const skeletonElements = screen.getAllByText((content, element) => {
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
});
// There are 8 skeleton divs in the component
expect(skeletonElements).toHaveLength(8);
});
});

View File

@@ -1,105 +0,0 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { NavigationLink } from "./NavigationLink";
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
}));
// Mock tooltip components
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-provider">{children}</div>
),
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-trigger">{children}</div>
),
}));
const defaultProps = {
href: "/test-link",
isActive: false,
isCollapsed: false,
children: <svg data-testid="icon" />,
linkText: "Test Link Text",
isTextVisible: true,
};
describe("NavigationLink", () => {
afterEach(() => {
cleanup();
});
test("renders expanded link correctly (inactive, text visible)", () => {
render(<NavigationLink {...defaultProps} />);
const linkElement = screen.getByRole("link");
const listItem = linkElement.closest("li");
const textSpan = screen.getByText(defaultProps.linkText);
expect(linkElement).toHaveAttribute("href", defaultProps.href);
expect(screen.getByTestId("icon")).toBeInTheDocument();
expect(textSpan).toBeInTheDocument();
expect(textSpan).toHaveClass("opacity-0");
expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
});
test("renders expanded link correctly (active, text hidden)", () => {
render(<NavigationLink {...defaultProps} isActive={true} isTextVisible={false} />);
const linkElement = screen.getByRole("link");
const listItem = linkElement.closest("li");
const textSpan = screen.getByText(defaultProps.linkText);
expect(linkElement).toHaveAttribute("href", defaultProps.href);
expect(screen.getByTestId("icon")).toBeInTheDocument();
expect(textSpan).toBeInTheDocument();
expect(textSpan).toHaveClass("opacity-100");
expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
});
test("renders collapsed link correctly (inactive)", () => {
render(<NavigationLink {...defaultProps} isCollapsed={true} />);
const linkElement = screen.getByRole("link");
const listItem = linkElement.closest("li");
expect(linkElement).toHaveAttribute("href", defaultProps.href);
expect(screen.getByTestId("icon")).toBeInTheDocument();
// Check text is NOT directly within the list item
expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
// Check tooltip elements
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
// Check text IS within the tooltip content mock
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
});
test("renders collapsed link correctly (active)", () => {
render(<NavigationLink {...defaultProps} isCollapsed={true} isActive={true} />);
const linkElement = screen.getByRole("link");
const listItem = linkElement.closest("li");
expect(linkElement).toHaveAttribute("href", defaultProps.href);
expect(screen.getByTestId("icon")).toBeInTheDocument();
// Check text is NOT directly within the list item
expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
// Check tooltip elements
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
// Check text IS within the tooltip content mock
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
});
});

View File

@@ -1,40 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectNavItem } from "./ProjectNavItem";
describe("ProjectNavItem", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
href: "/test-path",
children: <span>Test Child</span>,
};
test("renders correctly when active", () => {
render(<ProjectNavItem {...defaultProps} isActive={true} />);
const linkElement = screen.getByRole("link");
const listItem = linkElement.closest("li");
expect(linkElement).toHaveAttribute("href", "/test-path");
expect(screen.getByText("Test Child")).toBeInTheDocument();
expect(listItem).toHaveClass("bg-slate-50");
expect(listItem).toHaveClass("font-semibold");
expect(listItem).not.toHaveClass("hover:bg-slate-50");
});
test("renders correctly when inactive", () => {
render(<ProjectNavItem {...defaultProps} isActive={false} />);
const linkElement = screen.getByRole("link");
const listItem = linkElement.closest("li");
expect(linkElement).toHaveAttribute("href", "/test-path");
expect(screen.getByText("Test Child")).toBeInTheDocument();
expect(listItem).not.toHaveClass("bg-slate-50");
expect(listItem).not.toHaveClass("font-semibold");
expect(listItem).toHaveClass("hover:bg-slate-50");
});
});

View File

@@ -1,140 +0,0 @@
import { QuestionOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getTodayDate } from "@/app/lib/surveys/surveys";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ResponseFilterProvider, useResponseFilter } from "./ResponseFilterContext";
// Mock the getTodayDate function
vi.mock("@/app/lib/surveys/surveys", () => ({
getTodayDate: vi.fn(),
}));
const mockToday = new Date("2024-01-15T00:00:00.000Z");
const mockFromDate = new Date("2024-01-01T00:00:00.000Z");
// Test component to use the hook
const TestComponent = () => {
const {
selectedFilter,
setSelectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
} = useResponseFilter();
return (
<div>
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
<div data-testid="dateFrom">{dateRange.from?.toISOString()}</div>
<div data-testid="dateTo">{dateRange.to?.toISOString()}</div>
<button
onClick={() =>
setSelectedFilter({
filter: [
{
questionType: { id: "q1", label: "Question 1" },
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
},
],
onlyComplete: true,
})
}>
Update Filter
</button>
<button
onClick={() =>
setSelectedOptions({
questionOptions: [{ header: "q1" } as unknown as QuestionOptions],
questionFilterOptions: [{ id: "qFilterOpt1" } as unknown as QuestionFilterOptions],
})
}>
Update Options
</button>
<button onClick={() => setDateRange({ from: mockFromDate, to: mockToday })}>Update Date Range</button>
<button onClick={resetState}>Reset State</button>
</div>
);
};
describe("ResponseFilterContext", () => {
beforeEach(() => {
vi.mocked(getTodayDate).mockReturnValue(mockToday);
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
test("should provide initial state values", () => {
render(
<ResponseFilterProvider>
<TestComponent />
</ResponseFilterProvider>
);
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
expect(screen.getByTestId("filterLength").textContent).toBe("0");
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
expect(screen.getByTestId("dateFrom").textContent).toBe("");
expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
});
test("should update selectedFilter state", async () => {
render(
<ResponseFilterProvider>
<TestComponent />
</ResponseFilterProvider>
);
const updateButton = screen.getByText("Update Filter");
await userEvent.click(updateButton);
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
expect(screen.getByTestId("filterLength").textContent).toBe("1");
});
test("should update selectedOptions state", async () => {
render(
<ResponseFilterProvider>
<TestComponent />
</ResponseFilterProvider>
);
const updateButton = screen.getByText("Update Options");
await userEvent.click(updateButton);
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("1");
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("1");
});
test("should update dateRange state", async () => {
render(
<ResponseFilterProvider>
<TestComponent />
</ResponseFilterProvider>
);
const updateButton = screen.getByText("Update Date Range");
await userEvent.click(updateButton);
expect(screen.getByTestId("dateFrom").textContent).toBe(mockFromDate.toISOString());
expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
});
test("should throw error when useResponseFilter is used outside of Provider", () => {
// Hide console error temporarily
const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<TestComponent />)).toThrow("useFilterDate must be used within a FilterDateProvider");
consoleErrorMock.mockRestore();
});
});

View File

@@ -1,66 +0,0 @@
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TopControlBar } from "./TopControlBar";
// Mock the child component
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
TopControlButtons: vi.fn(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
}));
const mockEnvironment: TEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj1",
appSetupCompleted: true,
};
const mockEnvironments: TEnvironment[] = [
mockEnvironment,
{ ...mockEnvironment, id: "env2", type: "development" },
];
const mockMembershipRole: TOrganizationRole = "owner";
const mockProjectPermission = "manage";
describe("TopControlBar", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly and passes props to TopControlButtons", () => {
render(
<TopControlBar
environment={mockEnvironment}
environments={mockEnvironments}
membershipRole={mockMembershipRole}
projectPermission={mockProjectPermission}
/>
);
// Check if the main div is rendered
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
expect(mainDiv).toHaveClass(
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
);
// Check if the mocked child component is rendered
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
// Check if the child component received the correct props
expect(TopControlButtons).toHaveBeenCalledWith(
{
environment: mockEnvironment,
environments: mockEnvironments,
membershipRole: mockMembershipRole,
projectPermission: mockProjectPermission,
},
undefined // Updated from {} to undefined
);
});
});

View File

@@ -1,182 +0,0 @@
import { getAccessFlags } from "@/lib/membership/utils";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TopControlButtons } from "./TopControlButtons";
// Mock dependencies
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: mockPush })),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
vi.mock("@/modules/ee/teams/utils/teams", () => ({
getTeamPermissionFlags: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({
EnvironmentSwitch: vi.fn(() => <div data-testid="environment-switch">EnvironmentSwitch</div>),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => {
const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock
return (
<Tag onClick={onClick} data-testid={`button-${className}`} {...props}>
{children}
</Tag>
);
},
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
<div data-testid={`tooltip-${tooltipContent.split(".").pop()}`}>{children}</div>
),
}));
vi.mock("lucide-react", () => ({
BugIcon: () => <div data-testid="bug-icon" />,
CircleUserIcon: () => <div data-testid="circle-user-icon" />,
PlusIcon: () => <div data-testid="plus-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target} data-testid="link-mock">
{children}
</a>
),
}));
// Mock data
const mockEnvironmentDev: TEnvironment = {
id: "dev-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "project-id",
appSetupCompleted: true,
};
const mockEnvironmentProd: TEnvironment = {
id: "prod-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "project-id",
appSetupCompleted: true,
};
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
describe("TopControlButtons", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mocks for access flags
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: false,
isMember: false,
isBilling: false,
} as any);
vi.mocked(getTeamPermissionFlags).mockReturnValue({
hasReadAccess: false,
} as any);
});
afterEach(() => {
cleanup();
});
const renderComponent = (
membershipRole?: TOrganizationRole,
projectPermission: any = null,
isBilling = false,
hasReadAccess = false
) => {
vi.mocked(getAccessFlags).mockReturnValue({
isMember: membershipRole === "member",
isBilling: isBilling,
isOwner: membershipRole === "owner",
} as any);
vi.mocked(getTeamPermissionFlags).mockReturnValue({
hasReadAccess: hasReadAccess,
} as any);
return render(
<TopControlButtons
environment={mockEnvironmentDev}
environments={mockEnvironments}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
);
};
test("renders correctly for Owner role", async () => {
renderComponent("owner");
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
expect(screen.getByTestId("bug-icon")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
// Check link
const link = screen.getByTestId("link-mock");
expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues");
expect(link).toHaveAttribute("target", "_blank");
// Click account button
const accountButton = screen.getByTestId("circle-user-icon").closest("button");
await userEvent.click(accountButton!);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`);
});
// Click new survey button
const newSurveyButton = screen.getByTestId("plus-icon").closest("button");
await userEvent.click(newSurveyButton!);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`);
});
});
test("hides EnvironmentSwitch for Billing role", () => {
renderComponent(undefined, null, true); // isBilling = true
expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument();
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing
});
test("hides New Survey button for Billing role", () => {
renderComponent(undefined, null, true); // isBilling = true
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
});
test("hides New Survey button for read-only Member", () => {
renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
});
test("shows New Survey button for Member with write access", () => {
renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
});
});

View File

@@ -1,104 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { WidgetStatusIndicator } from "./WidgetStatusIndicator";
// Mock next/navigation
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: mockRefresh,
}),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
AlertTriangleIcon: () => <div data-testid="alert-icon">AlertTriangleIcon</div>,
CheckIcon: () => <div data-testid="check-icon">CheckIcon</div>,
RotateCcwIcon: () => <div data-testid="refresh-icon">RotateCcwIcon</div>,
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
const mockEnvironmentNotImplemented: TEnvironment = {
id: "env-not-implemented",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "proj1",
appSetupCompleted: false, // Not implemented state
};
const mockEnvironmentRunning: TEnvironment = {
id: "env-running",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj1",
appSetupCompleted: true, // Running state
};
describe("WidgetStatusIndicator", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly for 'notImplemented' state", () => {
render(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
// Check icon
expect(screen.getByTestId("alert-icon")).toBeInTheDocument();
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
// Check texts
expect(
screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected")
).toBeInTheDocument();
expect(
screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected_description")
).toBeInTheDocument();
// Check button
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
expect(recheckButton).toBeInTheDocument();
expect(screen.getByTestId("refresh-icon")).toBeInTheDocument();
});
test("renders correctly for 'running' state", () => {
render(<WidgetStatusIndicator environment={mockEnvironmentRunning} />);
// Check icon
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
expect(screen.queryByTestId("alert-icon")).not.toBeInTheDocument();
// Check texts
expect(screen.getByText("environments.project.app-connection.receiving_data")).toBeInTheDocument();
expect(
screen.getByText("environments.project.app-connection.formbricks_sdk_connected")
).toBeInTheDocument();
// Check button absence
expect(
screen.queryByRole("button", { name: /environments.project.app-connection.recheck/ })
).not.toBeInTheDocument();
expect(screen.queryByTestId("refresh-icon")).not.toBeInTheDocument();
});
test("calls router.refresh when 'Recheck' button is clicked", async () => {
render(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
await userEvent.click(recheckButton);
expect(mockRefresh).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,456 +0,0 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/navigation";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { AddIntegrationModal } from "./AddIntegrationModal";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown",
() => ({
BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => (
<div>
<label htmlFor="base">Base</label>
<select
id="base"
defaultValue={defaultValue}
onChange={(e) => {
control._mockOnChange({ target: { name: "base", value: e.target.value } });
setValue("table", ""); // Reset table when base changes
fetchTable(e.target.value);
}}>
<option value="">Select Base</option>
{airtableArray.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
fetchTables: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value, _locale) => value?.default || value || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey, _locale) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}) => (
<div data-testid="additional-settings">
<input
type="checkbox"
data-testid="include-variables"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-hidden"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-metadata"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
type="checkbox"
data-testid="include-createdat"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }) =>
open ? (
<div data-testid="modal">
{children}
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }) => <div data-testid="alert-title">{children}</div>,
AlertDescription: ({ children }) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props) => <img alt="test" {...props} />,
}));
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ refresh: vi.fn() })),
}));
// Mock the Select component used for Table and Survey selections
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children }) => (
// Render children, assuming Controller passes props to the Trigger/Value
// The actual select logic will be handled by the mocked Controller/field
// We need to simulate the structure expected by the Controller render prop
<div>{children}</div>
),
SelectTrigger: ({ children, ...props }) => <div {...props}>{children}</div>, // Mock Trigger
SelectValue: ({ placeholder }) => <span>{placeholder || "Select..."}</span>, // Mock Value display
SelectContent: ({ children }) => <div>{children}</div>, // Mock Content wrapper
SelectItem: ({ children, value, ...props }) => (
// Mock Item - crucial for userEvent.selectOptions if we were using a real select
// For Controller, the value change is handled by field.onChange directly
<div data-value={value} {...props}>
{children}
</div>
),
}));
// Mock react-hook-form Controller to render a simple select
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
let fields = {};
const mockReset = vi.fn((values) => {
fields = values || {}; // Reset fields, optionally with new values
});
return {
...actual,
useForm: vi.fn((options) => {
fields = options?.defaultValues || {};
const mockControlOnChange = (event) => {
if (event && event.target) {
fields[event.target.name] = event.target.value;
}
};
return {
handleSubmit: (fn) => (e) => {
e?.preventDefault();
fn(fields);
},
control: {
_mockOnChange: mockControlOnChange,
// Add other necessary control properties if needed
register: vi.fn(),
unregister: vi.fn(),
getFieldState: vi.fn(() => ({ invalid: false, isDirty: false, isTouched: false, error: null })),
_names: { mount: new Set(), unMount: new Set(), array: new Set(), watch: new Set() },
_options: {},
_proxyFormState: {
isDirty: false,
isValidating: false,
dirtyFields: {},
touchedFields: {},
errors: {},
},
_formState: { isDirty: false, isValidating: false, dirtyFields: {}, touchedFields: {}, errors: {} },
_updateFormState: vi.fn(),
_updateFieldArray: vi.fn(),
_executeSchema: vi.fn().mockResolvedValue({ errors: {}, values: {} }),
_getWatch: vi.fn(),
_subjects: {
watch: { subscribe: vi.fn() },
array: { subscribe: vi.fn() },
state: { subscribe: vi.fn() },
},
_getDirty: vi.fn(),
_reset: vi.fn(),
_removeUnmounted: vi.fn(),
},
watch: (name) => fields[name],
setValue: (name, value) => {
fields[name] = value;
},
reset: mockReset,
formState: { errors: {}, isDirty: false, isValid: true, isSubmitting: false },
getValues: (name) => (name ? fields[name] : fields),
};
}),
Controller: ({ name, defaultValue }) => {
// Initialize field value if not already set by reset/defaultValues
if (fields[name] === undefined && defaultValue !== undefined) {
fields[name] = defaultValue;
}
const field = {
onChange: (valueOrEvent) => {
const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent;
fields[name] = value;
// Re-render might be needed here in a real scenario, but testing library handles it
},
onBlur: vi.fn(),
value: fields[name],
name: name,
ref: vi.fn(),
};
// Find the corresponding label to associate with the select
const labelId = name; // Assuming label 'for' matches field name
const labelText =
name === "table" ? "environments.integrations.airtable.table_name" : "common.select_survey";
// Render a simple select element instead of the complex component
// This makes interaction straightforward with userEvent.selectOptions
return (
<>
{/* The actual label is rendered outside the Controller in the component */}
<select
id={labelId}
aria-label={labelText} // Use aria-label for accessibility in tests
{...field} // Spread field props
defaultValue={defaultValue} // Pass defaultValue
>
{/* Need to dynamically get options based on context, simplified here */}
{name === "table" &&
mockTables.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
{name === "survey" &&
mockSurveys.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</>
);
},
reset: mockReset,
};
});
const environmentId = "test-env-id";
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
questions: [
{ id: "q1", headline: { default: "Question 1" } },
{ id: "q2", headline: { default: "Question 2" } },
],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
variables: { enabled: true, fieldIds: ["var1"] },
} as any,
{
id: "survey2",
name: "Survey 2",
questions: [{ id: "q3", headline: { default: "Question 3" } }],
hiddenFields: { enabled: false },
variables: { enabled: false },
} as any,
];
const mockAirtableArray: TIntegrationItem[] = [
{ id: "base1", name: "Base 1" },
{ id: "base2", name: "Base 2" },
];
const mockAirtableIntegration: TIntegrationAirtable = {
id: "integration1",
type: "airtable",
environmentId,
config: {
key: { access_token: "abc" } as TIntegrationAirtableCredential,
email: "test@test.com",
data: [],
},
};
const mockTables: TIntegrationAirtableTables["tables"] = [
{ id: "table1", name: "Table 1" },
{ id: "table2", name: "Table 2" },
];
const mockSetOpenWithStates = vi.fn();
const mockRouterRefresh = vi.fn();
describe("AddIntegrationModal", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.mocked(useRouter).mockReturnValue({ refresh: mockRouterRefresh } as any);
});
afterEach(() => {
cleanup();
});
test("renders in add mode correctly", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByText("environments.integrations.airtable.link_airtable_table")).toBeInTheDocument();
expect(screen.getByLabelText("Base")).toBeInTheDocument();
// Use getByLabelText for the mocked selects
expect(screen.getByLabelText("environments.integrations.airtable.table_name")).toBeInTheDocument();
expect(screen.getByLabelText("common.select_survey")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
});
test("shows 'No Base Found' error when airtableArray is empty", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={[]}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByTestId("alert-title")).toHaveTextContent(
"environments.integrations.airtable.no_bases_found"
);
});
test("shows 'No Surveys Found' warning when surveys array is empty", () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={[]}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
expect(screen.getByText("environments.integrations.create_survey_warning")).toBeInTheDocument();
});
test("fetches and displays tables when a base is selected", async () => {
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
const baseSelect = screen.getByLabelText("Base");
await userEvent.selectOptions(baseSelect, "base1");
expect(fetchTables).toHaveBeenCalledWith(environmentId, "base1");
await waitFor(() => {
// Use getByLabelText (mocked select)
const tableSelect = screen.getByLabelText("environments.integrations.airtable.table_name");
expect(tableSelect).toBeEnabled();
// Check options within the mocked select
expect(tableSelect.querySelector("option[value='table1']")).toBeInTheDocument();
expect(tableSelect.querySelector("option[value='table2']")).toBeInTheDocument();
});
});
test("handles deletion in edit mode", async () => {
const initialData: TIntegrationAirtableConfigData = {
baseId: "base1",
tableId: "table1",
surveyId: "survey1",
questionIds: ["q1"],
questions: "common.selected_questions",
tableName: "Table 1",
surveyName: "Survey 1",
createdAt: new Date(),
includeVariables: false,
includeHiddenFields: false,
includeMetadata: false,
includeCreatedAt: true,
};
const integrationWithData = {
...mockAirtableIntegration,
config: { ...mockAirtableIntegration.config, data: [initialData] },
};
const defaultData = { ...initialData, index: 0 } as any;
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
vi.mocked(createOrUpdateIntegrationAction).mockResolvedValue({ ok: true, data: {} } as any);
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={integrationWithData}
isEditMode={true}
defaultData={defaultData}
/>
);
await waitFor(() => expect(fetchTables).toHaveBeenCalled()); // Wait for initial load
// Click delete
await userEvent.click(screen.getByText("common.delete"));
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledTimes(1);
const submittedData = vi.mocked(createOrUpdateIntegrationAction).mock.calls[0][0].integrationData;
// Expect data array to be empty after deletion
expect(submittedData.config.data).toHaveLength(0);
});
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
expect(mockRouterRefresh).toHaveBeenCalled();
});
test("handles cancel button click", async () => {
render(
<AddIntegrationModal
open={true}
setOpenWithStates={mockSetOpenWithStates}
environmentId={environmentId}
airtableArray={mockAirtableArray}
surveys={mockSurveys}
airtableIntegration={mockAirtableIntegration}
isEditMode={false}
/>
);
await userEvent.click(screen.getByText("common.cancel"));
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,134 +0,0 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "./AirtableWrapper";
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration",
() => ({
ManageIntegration: ({ setIsConnected }) => (
<div data-testid="manage-integration">
<button onClick={() => setIsConnected(false)}>Disconnect</button>
</div>
),
})
);
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: ({ handleAuthorization, isEnabled }) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization} disabled={!isEnabled}>
Connect
</button>
</div>
),
}));
// Mock library function
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
authorize: vi.fn(),
}));
// Mock image import
vi.mock("@/images/airtableLogo.svg", () => ({
default: "airtable-logo-path",
}));
// Mock window.location.replace
Object.defineProperty(window, "location", {
value: {
replace: vi.fn(),
},
writable: true,
});
const environmentId = "test-env-id";
const webAppUrl = "https://app.formbricks.com";
const environment = { id: environmentId } as TEnvironment;
const surveys = [];
const airtableArray = [];
const locale = "en-US" as const;
const baseProps = {
environmentId,
airtableArray,
surveys,
environment,
webAppUrl,
locale,
};
describe("AirtableWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected (no integration)", () => {
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
});
test("renders ConnectIntegration when not connected (integration without key)", () => {
const integrationWithoutKey = { config: {} } as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={integrationWithoutKey} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration disabled when isEnabled is false", () => {
render(<AirtableWrapper {...baseProps} isEnabled={false} airtableIntegration={undefined} />);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
});
test("calls authorize and redirects when Connect button is clicked", async () => {
const mockAuthorize = vi.mocked(authorize);
const redirectUrl = "https://airtable.com/auth";
mockAuthorize.mockResolvedValue(redirectUrl);
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
const connectButton = screen.getByRole("button", { name: "Connect" });
await userEvent.click(connectButton);
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
await vi.waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
});
});
test("renders ManageIntegration when connected", () => {
const connectedIntegration = {
id: "int-1",
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
} as unknown as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
});
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
const connectedIntegration = {
id: "int-1",
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
} as unknown as TIntegrationAirtable;
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
// Initially, ManageIntegration is shown
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
// Simulate disconnection via ManageIntegration's button
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
await userEvent.click(disconnectButton);
// Now, ConnectIntegration should be shown
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
});

View File

@@ -1,125 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { useForm } from "react-hook-form";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TIntegrationItem } from "@formbricks/types/integration";
import { IntegrationModalInputs } from "./AddIntegrationModal";
import { BaseSelectDropdown } from "./BaseSelectDropdown";
// Mock UI components
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) => (
<label htmlFor={htmlFor}>{children}</label>
),
}));
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, onValueChange, disabled, defaultValue }) => (
<select
data-testid="base-select"
onChange={(e) => onValueChange(e.target.value)}
disabled={disabled}
defaultValue={defaultValue}>
{children}
</select>
),
SelectTrigger: ({ children }) => <div>{children}</div>,
SelectValue: () => <span>SelectValueMock</span>,
SelectContent: ({ children }) => <div>{children}</div>,
SelectItem: ({ children, value }) => <option value={value}>{children}</option>,
}));
// Mock react-hook-form's Controller specifically
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
// Keep the actual useForm
const originalUseForm = actual.useForm;
// Mock Controller
const MockController = ({ name, _, render, defaultValue }) => {
// Minimal mock: call render with a basic field object
const field = {
onChange: vi.fn(), // Simple spy for field.onChange
onBlur: vi.fn(),
value: defaultValue, // Use defaultValue passed to Controller
name: name,
ref: vi.fn(),
};
// The component passes the render prop result to the actual Select component
return render({ field });
};
return {
...actual,
useForm: originalUseForm, // Use the actual useForm
Controller: MockController, // Use the mocked Controller
};
});
const mockAirtableArray: TIntegrationItem[] = [
{ id: "base1", name: "Base One" },
{ id: "base2", name: "Base Two" },
];
const mockFetchTable = vi.fn();
// Use a wrapper component that utilizes the actual useForm
const renderComponent = (
isLoading = false,
defaultValue: string | undefined = undefined,
airtableArray = mockAirtableArray
) => {
const Component = () => {
// Now uses the actual useForm because Controller is mocked separately
const { control, setValue } = useForm<IntegrationModalInputs>({
defaultValues: { base: defaultValue },
});
return (
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={mockFetchTable} // The spy
airtableArray={airtableArray}
setValue={setValue} // Actual RHF setValue
defaultValue={defaultValue}
/>
);
};
return render(<Component />);
};
describe("BaseSelectDropdown", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the label and select trigger", () => {
renderComponent();
expect(screen.getByText("environments.integrations.airtable.airtable_base")).toBeInTheDocument();
expect(screen.getByTestId("base-select")).toBeInTheDocument();
expect(screen.getByText("SelectValueMock")).toBeInTheDocument(); // From mocked SelectValue
});
test("renders options from airtableArray", () => {
renderComponent();
const select = screen.getByTestId("base-select");
expect(select.querySelectorAll("option")).toHaveLength(mockAirtableArray.length);
expect(screen.getByText("Base One")).toBeInTheDocument();
expect(screen.getByText("Base Two")).toBeInTheDocument();
});
test("disables the select when isLoading is true", () => {
renderComponent(true);
expect(screen.getByTestId("base-select")).toBeDisabled();
});
test("enables the select when isLoading is false", () => {
renderComponent(false);
expect(screen.getByTestId("base-select")).toBeEnabled();
});
test("renders correctly with empty airtableArray", () => {
renderComponent(false, undefined, []);
const select = screen.getByTestId("base-select");
expect(select.querySelectorAll("option")).toHaveLength(0);
});
});

View File

@@ -1,85 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
import { authorize, fetchTables } from "./airtable";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
global.fetch = vi.fn();
const environmentId = "test-env-id";
const baseId = "test-base-id";
const apiHost = "http://localhost:3000";
describe("Airtable Library", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("fetchTables", () => {
test("should fetch tables successfully", async () => {
const mockTables: TIntegrationAirtableTables = {
tables: [
{ id: "tbl1", name: "Table 1" },
{ id: "tbl2", name: "Table 2" },
],
};
const mockResponse = {
ok: true,
json: async () => ({ data: mockTables }),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const tables = await fetchTables(environmentId, baseId);
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET",
headers: { environmentId: environmentId },
cache: "no-store",
});
expect(tables).toEqual(mockTables);
});
});
describe("authorize", () => {
test("should return authUrl successfully", async () => {
const mockAuthUrl = "https://airtable.com/oauth2/v1/authorize?...";
const mockResponse = {
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const authUrl = await authorize(environmentId, apiHost);
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
});
expect(authUrl).toBe(mockAuthUrl);
});
test("should throw error and log when fetch fails", async () => {
const errorText = "Failed to fetch";
const mockResponse = {
ok: false,
text: async () => errorText,
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
});
});
});

View File

@@ -1,217 +0,0 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable, TIntegrationAirtableCredential } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import Page from "./page";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys");
vi.mock("@/lib/airtable/service");
let mockAirtableClientId: string | undefined = "test-client-id";
vi.mock("@/lib/constants", () => ({
get AIRTABLE_CLIENT_ID() {
return mockAirtableClientId;
},
WEBAPP_URL: "http://localhost:3000",
IS_PRODUCTION: true,
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",
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/integration/service");
vi.mock("@/lib/utils/locale");
vi.mock("@/modules/environments/lib/utils");
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(() => <div>GoBackButton Mock</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation");
const mockEnvironmentId = "test-env-id";
const mockEnvironment = {
id: mockEnvironmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [{ id: "survey1", name: "Survey 1" } as TSurvey];
const mockAirtableIntegration: TIntegrationAirtable = {
type: "airtable",
config: {
key: { access_token: "test-token" } as unknown as TIntegrationAirtableCredential,
data: [],
email: "test@example.com",
},
environmentId: mockEnvironmentId,
id: "int_airtable_123",
};
const mockAirtableTables: TIntegrationItem[] = [{ id: "table1", name: "Table 1" } as TIntegrationItem];
const mockLocale = "en-US";
const props = {
params: {
environmentId: mockEnvironmentId,
},
};
describe("Airtable Integration Page", () => {
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as unknown as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockAirtableIntegration]);
vi.mocked(getAirtableTables).mockResolvedValue(mockAirtableTables);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects if user is readOnly", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as unknown as TEnvironmentAuth);
await render(await Page(props));
expect(redirect).toHaveBeenCalledWith("./");
});
test("renders correctly when integration is configured", async () => {
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("GoBackButton Mock")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurveys)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getIntegrations)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getAirtableTables)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(findMatchingLocale)).toHaveBeenCalled();
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
expect(AirtableWrapper).toHaveBeenCalledWith(
{
isEnabled: true,
airtableIntegration: mockAirtableIntegration,
airtableArray: mockAirtableTables,
environmentId: mockEnvironmentId,
surveys: mockSurveys,
environment: mockEnvironment,
webAppUrl: WEBAPP_URL,
locale: mockLocale,
},
undefined
);
});
test("renders correctly when integration exists but is not configured (no key)", async () => {
const integrationWithoutKey = {
...mockAirtableIntegration,
config: { ...mockAirtableIntegration.config, key: undefined },
} as unknown as TIntegrationAirtable;
vi.mocked(getIntegrations).mockResolvedValue([integrationWithoutKey]);
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
expect(vi.mocked(getAirtableTables)).not.toHaveBeenCalled(); // Should not fetch tables if no key
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
// Update assertion to match the actual call
expect(AirtableWrapper).toHaveBeenCalledWith(
{
isEnabled: true, // isEnabled is true because AIRTABLE_CLIENT_ID is set in beforeEach
airtableIntegration: integrationWithoutKey,
airtableArray: [], // Should be empty as getAirtableTables is not called
environmentId: mockEnvironmentId,
surveys: mockSurveys,
environment: mockEnvironment,
webAppUrl: WEBAPP_URL,
locale: mockLocale,
},
undefined // Change second argument to undefined
);
});
test("renders correctly when integration is disabled (no client ID)", async () => {
mockAirtableClientId = undefined; // Simulate disabled integration
await render(await Page(props));
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
const AirtableWrapper = vi.mocked(
(
await import(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
)
).AirtableWrapper
);
expect(AirtableWrapper).toHaveBeenCalledWith(
expect.objectContaining({
isEnabled: false, // Should be false
}),
undefined
);
});
});

View File

@@ -1,694 +0,0 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({
getSpreadsheetNameByIdAction: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({
constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`,
extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5],
isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: any) => (
<div>
<span>Additional Settings</span>
<input
data-testid="include-variables"
type="checkbox"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
data-testid="include-hidden-fields"
type="checkbox"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
data-testid="include-metadata"
type="checkbox"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
data-testid="include-created-at"
type="checkbox"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem }: any) => (
<div>
<label>{label}</label>
<select
data-testid="survey-dropdown"
value={selectedItem?.id || ""}
onChange={(e) => {
const selected = items.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}>
<option value="">Select a survey</option>
{items.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, _?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.google_sheets.link_google_sheet") return "Link Google Sheet";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "environments.integrations.google_sheets.spreadsheet_url") return "Spreadsheet URL";
if (key === "common.select_survey") return "Select survey";
if (key === "common.questions") return "Questions";
if (key === "environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error")
return "Please enter a valid Google Sheet URL.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.select_at_least_one_question_error")
return "Please select at least one question.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.google_sheets.google_sheet_logo") return "Google Sheet logo";
if (key === "environments.integrations.google_sheets.google_sheets_integration_description")
return "Sync responses with Google Sheets.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const getSpreadsheetNameByIdAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"))
.getSpreadsheetNameByIdAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate this?" },
required: true,
scale: "number",
range: 5,
} as unknown as TSurveyQuestion,
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const mockGoogleSheetIntegration = {
id: "integration1",
type: "googleSheets",
config: {
key: {
access_token: "mock_access_token",
expiry_date: Date.now() + 3600000,
refresh_token: "mock_refresh_token",
scope: "mock_scope",
token_type: "Bearer",
},
email: "test@example.com",
data: [], // Initially empty, will be populated in beforeEach
},
} as unknown as TIntegrationGoogleSheets;
const mockSelectedIntegration: TIntegrationGoogleSheetsConfigData & { index: number } = {
spreadsheetId: "existing-sheet-id",
spreadsheetName: "Existing Sheet",
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: [surveys[0].questions[0].id],
questions: "Selected questions",
createdAt: new Date(),
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: false,
index: 0,
};
describe("AddIntegrationModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockGoogleSheetIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toBeInTheDocument();
// Use getByTestId for the dropdown
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Link Google Sheet" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toHaveValue("https://docs.google.com/spreadsheets/d/existing-sheet-id");
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
expect(screen.getByTestId("include-variables")).toBeChecked();
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
expect(screen.getByTestId("include-metadata")).toBeChecked();
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
});
test("selects survey and shows questions", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
surveys[1].questions.forEach((q) => {
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
// Initially all questions should be checked when a survey is selected in create mode
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
});
});
test("handles question selection", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
});
test("creates integration successfully", async () => {
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Test Sheet Name" });
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); // Mock successful action
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={{
...mockGoogleSheetIntegration,
config: { ...mockGoogleSheetIntegration.config, data: [] },
}} // Start with empty data
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/new-sheet-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Wait for questions to appear and potentially uncheck one
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
// Check additional settings
await userEvent.click(screen.getByTestId("include-variables"));
await userEvent.click(screen.getByTestId("include-metadata"));
await userEvent.click(submitButton);
await waitFor(() => {
expect(getSpreadsheetNameByIdAction).toHaveBeenCalledWith({
googleSheetIntegration: expect.any(Object),
environmentId,
spreadsheetId: "new-sheet-id",
});
});
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
type: "googleSheets",
config: expect.objectContaining({
key: mockGoogleSheetIntegration.config.key,
email: mockGoogleSheetIntegration.config.email,
data: expect.arrayContaining([
expect.objectContaining({
spreadsheetId: "new-sheet-id",
spreadsheetName: "Test Sheet Name",
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
questions: "Selected questions",
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: true, // Default
}),
]),
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration} // Contains initial data at index 0
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error for invalid URL", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "invalid-url");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please enter a valid Google Sheet URL.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no survey selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
// No survey selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no questions selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Uncheck all questions
for (const question of surveys[0].questions) {
const checkbox = await screen.findByLabelText(question.headline.default);
await userEvent.click(checkbox);
}
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
});
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
const errorMessage = "Failed to update integration";
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Some Sheet Name" });
createOrUpdateIntegrationAction.mockRejectedValue(new Error(errorMessage));
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/another-id");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(getSpreadsheetNameByIdAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input
const urlInput = screen.getByPlaceholderText(
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
);
const cancelButton = screen.getByText("Cancel");
// Simulate some interaction
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/temp-id");
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset (URL should be empty)
cleanup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
googleSheetIntegration={mockGoogleSheetIntegration}
selectedIntegration={null}
/>
);
// Use getByPlaceholderText for the input check after re-render
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
).toHaveValue("");
});
});

View File

@@ -1,175 +0,0 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsCredential,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock child components and functions
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration",
() => ({
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => (
<div data-testid="manage-integration">
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
</div>
)),
})
);
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(({ handleAuthorization }) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization}>Connect</button>
</div>
)),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal",
() => ({
AddIntegrationModal: vi.fn(({ open }) =>
open ? <div data-testid="add-integration-modal">Modal</div> : null
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({
authorize: vi.fn(() => Promise.resolve("http://google.com/auth")),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [];
const mockWebAppUrl = "http://localhost:3000";
const mockLocale = "en-US";
const mockGoogleSheetIntegration = {
id: "test-integration-id",
type: "googleSheets",
config: {
key: { access_token: "test-token" } as unknown as TIntegrationGoogleSheetsCredential,
data: [],
email: "test@example.com",
},
} as unknown as TIntegrationGoogleSheets;
describe("GoogleSheetWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected", () => {
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
// No googleSheetIntegration provided initially
/>
);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
});
test("renders ConnectIntegration when integration exists but has no key", () => {
const integrationWithoutKey = {
...mockGoogleSheetIntegration,
config: { data: [], email: "test" },
} as unknown as TIntegrationGoogleSheets;
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={integrationWithoutKey}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("calls authorize when connect button is clicked", async () => {
const user = userEvent.setup();
// Mock window.location.replace
const originalLocation = window.location;
// @ts-expect-error
delete window.location;
window.location = { ...originalLocation, replace: vi.fn() } as any;
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
const connectButton = screen.getByRole("button", { name: "Connect" });
await user.click(connectButton);
expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
// Need to wait for the promise returned by authorize to resolve
await vi.waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth");
});
// Restore window.location
window.location = originalLocation as any;
});
test("renders ManageIntegration and AddIntegrationModal when connected", () => {
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={mockGoogleSheetIntegration}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
// Modal is rendered but initially hidden
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
});
test("opens AddIntegrationModal when triggered from ManageIntegration", async () => {
const user = userEvent.setup();
render(
<GoogleSheetWrapper
isEnabled={true}
environment={mockEnvironment}
surveys={mockSurveys}
googleSheetIntegration={mockGoogleSheetIntegration}
webAppUrl={mockWebAppUrl}
locale={mockLocale}
/>
);
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
const openModalButton = screen.getByRole("button", { name: "Open Modal" }); // Button inside mocked ManageIntegration
await user.click(openModalButton);
expect(screen.getByTestId("add-integration-modal")).toBeInTheDocument();
});
});

View File

@@ -1,61 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { authorize } from "./google";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/google-sheet`;
const expectedHeaders = { environmentId: environmentId };
afterEach(() => {
vi.clearAllMocks();
});
test("should return authUrl on successful fetch", async () => {
const mockAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?...";
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(authUrl).toBe(mockAuthUrl);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw error and log on failed fetch", async () => {
const errorText = "Failed to fetch";
mockFetch.mockResolvedValueOnce({
ok: false,
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(logger.error).toHaveBeenCalledWith(
{ errorText },
"authorize: Could not fetch google sheet config"
);
});
});

View File

@@ -1,50 +0,0 @@
import { describe, expect, test } from "vitest";
import { constructGoogleSheetsUrl, extractSpreadsheetIdFromUrl, isValidGoogleSheetsUrl } from "./util";
describe("Google Sheets Util", () => {
describe("extractSpreadsheetIdFromUrl", () => {
test("should extract spreadsheet ID from a valid URL", () => {
const url =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
const expectedId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
expect(extractSpreadsheetIdFromUrl(url)).toBe(expectedId);
});
test("should throw an error for an invalid URL", () => {
const invalidUrl = "https://not-a-google-sheet-url.com";
expect(() => extractSpreadsheetIdFromUrl(invalidUrl)).toThrow("Invalid Google Sheets URL");
});
test("should throw an error for a URL without an ID", () => {
const urlWithoutId = "https://docs.google.com/spreadsheets/d/";
expect(() => extractSpreadsheetIdFromUrl(urlWithoutId)).toThrow("Invalid Google Sheets URL");
});
});
describe("constructGoogleSheetsUrl", () => {
test("should construct a valid Google Sheets URL from a spreadsheet ID", () => {
const spreadsheetId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
const expectedUrl =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
expect(constructGoogleSheetsUrl(spreadsheetId)).toBe(expectedUrl);
});
});
describe("isValidGoogleSheetsUrl", () => {
test("should return true for a valid Google Sheets URL", () => {
const validUrl =
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
expect(isValidGoogleSheetsUrl(validUrl)).toBe(true);
});
test("should return false for an invalid URL", () => {
const invalidUrl = "https://not-a-google-sheet-url.com";
expect(isValidGoogleSheetsUrl(invalidUrl)).toBe(false);
});
test("should return true for a base Google Sheets URL", () => {
const baseUrl = "https://docs.google.com/spreadsheets/d/";
expect(isValidGoogleSheetsUrl(baseUrl)).toBe(true);
});
});
});

View File

@@ -1,40 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock the GoBackButton component
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: () => <div>GoBackButton</div>,
}));
describe("Loading", () => {
afterEach(() => {
cleanup();
});
test("renders the loading state correctly", () => {
render(<Loading />);
// Check for GoBackButton mock
expect(screen.getByText("GoBackButton")).toBeInTheDocument();
// Check for the disabled button text
expect(screen.getByText("environments.integrations.google_sheets.link_new_sheet")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.google_sheets.link_new_sheet").closest("button")
).toHaveClass("pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none");
// Check for table headers
expect(screen.getByText("common.survey")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.google_sheets.google_sheet_name")).toBeInTheDocument();
expect(screen.getByText("common.questions")).toBeInTheDocument();
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
// Check for placeholder elements (count based on the loop)
const placeholders = screen.getAllByRole("generic", { hidden: true }); // Using generic role as divs don't have implicit roles
// Calculate expected placeholders: 3 rows * 5 placeholders per row = 15
// Plus the button, header divs (4), and the main containers
// It's simpler to check if there are *any* pulse animations
expect(placeholders.some((el) => el.classList.contains("animate-pulse"))).toBe(true);
});
});

View File

@@ -1,228 +0,0 @@
import Page from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getIntegrations } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsCredential,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper",
() => ({
GoogleSheetWrapper: vi.fn(
({ isEnabled, environment, surveys, googleSheetIntegration, webAppUrl, locale }) => (
<div>
<span>Mocked GoogleSheetWrapper</span>
<span data-testid="isEnabled">{isEnabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{googleSheetIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
})
);
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
let mockGoogleSheetClientId: string | undefined = "test-client-id";
vi.mock("@/lib/constants", () => ({
get GOOGLE_SHEETS_CLIENT_ID() {
return mockGoogleSheetClientId;
},
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrations: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockGoogleSheetIntegration = {
id: "integration1",
type: "googleSheets",
config: {
data: [],
key: {
refresh_token: "refresh",
access_token: "access",
expiry_date: Date.now() + 3600000,
} as unknown as TIntegrationGoogleSheetsCredential,
email: "test@example.com",
},
} as unknown as TIntegrationGoogleSheets;
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("GoogleSheetsIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
});
test("renders the page with GoogleSheetWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(
screen.getByText("environments.integrations.google_sheets.google_sheets_integration")
).toBeInTheDocument();
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
expect(screen.getByTestId("isEnabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockGoogleSheetIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
test("passes isEnabled=false to GoogleSheetWrapper when constants are missing", async () => {
mockGoogleSheetClientId = undefined;
const { default: PageWithMissingConstants } = (await import(
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/page"
)) as { default: typeof Page };
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
const PageComponent = await PageWithMissingConstants(mockProps);
render(PageComponent);
expect(screen.getByTestId("isEnabled")).toHaveTextContent("false");
});
test("handles case where no Google Sheet integration exists", async () => {
vi.mocked(getIntegrations).mockResolvedValue([]); // No integrations
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
});
});

View File

@@ -1,172 +0,0 @@
import { cache } from "@/lib/cache";
import { surveyCache } from "@/lib/survey/cache";
import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getSurveys } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
tag: {
byEnvironmentId: vi.fn((environmentId) => `survey_environment_${environmentId}`),
},
},
}));
vi.mock("@/lib/survey/service", () => ({
selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage
}));
vi.mock("@/lib/survey/utils");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
cache: vi.fn((fn) => fn), // Mock reactCache to just return the function
};
});
const environmentId = "test-environment-id";
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock
const mockPrismaSurveys = [
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
{ id: "survey2", name: "Survey 2", status: "draft", updatedAt: new Date() },
];
const mockTransformedSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app", // Changed type to web to match original file
environmentId: environmentId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey,
{
id: "survey2",
name: "Survey 2",
status: "draft",
questions: [],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app",
environmentId: environmentId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey,
];
describe("getSurveys", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should fetch and transform surveys successfully", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => {
const found = mockTransformedSurveys.find((ts) => ts.id === survey.id);
if (!found) throw new Error("Survey not found in mock transformed data");
// Ensure the returned object matches the TSurvey structure precisely
return { ...found } as TSurvey;
});
const surveys = await getSurveys(environmentId);
expect(surveys).toEqual(mockTransformedSurveys);
// Use expect.any(ZId) for the Zod schema validation check
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
status: {
not: "completed",
},
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
});
expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
// Check if the inner cache function was called with the correct arguments
expect(cache).toHaveBeenCalledWith(
expect.any(Function), // The async function passed to cache
[`getSurveys-${environmentId}`], // The cache key
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)], // Cache tags
}
);
// Remove the assertion for reactCache being called within the test execution
// expect(reactCache).toHaveBeenCalled(); // Removed this line
});
test("should throw DatabaseError on Prisma known request error", async () => {
// No need to mock cache here again as beforeEach handles it
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2025",
clientVersion: "5.0.0",
meta: {}, // Added meta property
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
});
test("should throw original error on other errors", async () => {
// No need to mock cache here again as beforeEach handles it
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
});
});

View File

@@ -1,114 +0,0 @@
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate";
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 { getWebhookCountBySource } from "./webhook";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byEnvironmentIdAndSource: vi.fn((envId, source) => `webhook_${envId}_${source ?? "all"}`),
},
},
}));
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
count: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return webhook count for a specific source", async () => {
const mockCount = 5;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId, sourceZapier);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[sourceZapier, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
source: sourceZapier,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-${sourceZapier}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, sourceZapier)],
}
);
});
test("should return total webhook count when source is undefined", async () => {
const mockCount = 10;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[undefined, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
source: undefined,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getWebhookCountBySource-${environmentId}-undefined`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, undefined)],
}
);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError);
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw original error on other errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError);
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,606 +0,0 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionCredential,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionTypes: () => [
{ id: TSurveyQuestionTypeEnum.OpenText, label: "Open Text" },
{ id: TSurveyQuestionTypeEnum.MultipleChoiceSingle, label: "Multiple Choice Single" },
{ id: TSurveyQuestionTypeEnum.Date, label: "Date" },
],
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
// Ensure the selected item is always available as an option
const allOptions = [...items];
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
// Use a simple object structure consistent with how options are likely used
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
}
// Remove duplicates just in case
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
return (
<div>
{label && <label>{label}</label>}
<select
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
onChange={(e) => {
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">{placeholder || "Select..."}</option>
{/* Render options from the potentially augmented list */}
{uniqueOptions.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
);
},
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
XIcon: () => <span data-testid="x-icon">x</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, params?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.warning") return "Warning";
if (key === "common.metadata") return "Metadata";
if (key === "common.created_at") return "Created at";
if (key === "common.hidden_field") return "Hidden Field";
if (key === "environments.integrations.notion.link_notion_database") return "Link Notion Database";
if (key === "environments.integrations.notion.sync_responses_with_a_notion_database")
return "Sync responses with a Notion database.";
if (key === "environments.integrations.notion.select_a_database") return "Select a database";
if (key === "common.select_survey") return "Select survey";
if (key === "environments.integrations.notion.map_formbricks_fields_to_notion_property")
return "Map Formbricks fields to Notion property";
if (key === "environments.integrations.notion.select_a_survey_question")
return "Select a survey question";
if (key === "environments.integrations.notion.select_a_field_to_map") return "Select a field to map";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "common.update") return "Update";
if (key === "environments.integrations.notion.please_select_a_database")
return "Please select a database.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.notion.please_select_at_least_one_mapping")
return "Please select at least one mapping.";
if (key === "environments.integrations.notion.please_resolve_mapping_errors")
return "Please resolve mapping errors.";
if (key === "environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
return "Please complete mapping fields.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.notion.notion_logo") return "Notion logo";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.notion.create_at_least_one_database_to_setup_this_integration")
return "Create at least one database.";
if (key === "environments.integrations.notion.duplicate_connection_warning")
return "Duplicate connection warning.";
if (key === "environments.integrations.notion.que_name_of_type_cant_be_mapped_to")
return `Question ${params.que_name} (${params.question_label}) can't be mapped to ${params.col_name} (${params.col_type}). Allowed types: ${params.mapped_type}`;
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
variables: [{ id: "var1", name: "Variable 1" }],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Date,
headline: { default: "Date Question?" },
required: true,
} as unknown as TSurveyQuestion,
],
variables: [],
hiddenFields: { enabled: false },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const databases: TIntegrationNotionDatabase[] = [
{
id: "db1",
name: "Database 1 Title",
properties: {
prop1: { id: "p1", name: "Title Prop", type: "title" },
prop2: { id: "p2", name: "Text Prop", type: "rich_text" },
prop3: { id: "p3", name: "Number Prop", type: "number" },
prop4: { id: "p4", name: "Date Prop", type: "date" },
prop5: { id: "p5", name: "Unsupported Prop", type: "formula" }, // Unsupported
},
},
{
id: "db2",
name: "Database 2 Title",
properties: {
propA: { id: "pa", name: "Name", type: "title" },
propB: { id: "pb", name: "Email", type: "email" },
},
},
];
const mockNotionIntegration: TIntegrationNotion = {
id: "integration1",
type: "notion",
environmentId: environmentId,
config: {
key: {
access_token: "token",
bot_id: "bot",
workspace_name: "ws",
workspace_icon: "",
} as unknown as TIntegrationNotionCredential,
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationNotionConfigData & { index: number } = {
databaseId: databases[0].id,
databaseName: databases[0].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
mapping: [
{
column: { id: "p1", name: "Title Prop", type: "title" },
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
},
{
column: { id: "p2", name: "Text Prop", type: "rich_text" },
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
},
],
createdAt: new Date(),
index: 0,
};
describe("AddIntegrationModal (Notion)", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockNotionIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Map Formbricks fields to Notion property")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={mockNotionIntegration}
databases={databases}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
// Check if mapping rows are rendered
await waitFor(() => {
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
const columnDropdowns = screen.getAllByTestId("dropdown-select-a-field-to-map");
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
expect(columnDropdowns).toHaveLength(2);
// Assert values for the first row
expect(questionDropdowns[0]).toHaveValue("q1");
expect(columnDropdowns[0]).toHaveValue("p1");
// Assert values for the second row
expect(questionDropdowns[1]).toHaveValue("var1");
expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
});
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
});
test("selects database and survey, shows mapping", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await userEvent.selectOptions(dbDropdown, databases[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-survey-question")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-field-to-map")).toBeInTheDocument();
});
test("adds and removes mapping rows", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await userEvent.selectOptions(dbDropdown, databases[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
const plusButton = screen.getByTestId("plus-icon");
await userEvent.click(plusButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
await userEvent.click(xButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={mockNotionIntegration} // Contains initial data at index 0
databases={databases}
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error if no database selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a database.");
});
});
test("shows validation error if no survey selected", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
});
test("shows validation error if no mapping defined", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
// Default mapping row is empty
await userEvent.click(
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one mapping.");
});
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
const cancelButton = screen.getByText("Cancel");
await userEvent.selectOptions(dbDropdown, databases[0].id); // Simulate interaction
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset
cleanup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
notionIntegration={{
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, data: [] },
}}
databases={databases}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(""); // Should be reset
});
});

View File

@@ -1,152 +0,0 @@
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionCredential } from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
import { NotionWrapper } from "./NotionWrapper";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
}));
// Mock child components
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration", () => ({
ManageIntegration: vi.fn(({ setIsConnected }) => (
<div data-testid="manage-integration">
<button onClick={() => setIsConnected(false)}>Disconnect</button>
</div>
)),
}));
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(
(
{ handleAuthorization, isEnabled } // Reverted back to isEnabled
) => (
<div data-testid="connect-integration">
<button onClick={handleAuthorization} disabled={!isEnabled}>
{" "}
{/* Reverted back to isEnabled */}
Connect
</button>
</div>
)
),
}));
// Mock library function
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion", () => ({
authorize: vi.fn(),
}));
// Mock image import
vi.mock("@/images/notion-logo.svg", () => ({
default: "notion-logo-path",
}));
// Mock window.location.replace
Object.defineProperty(window, "location", {
value: {
replace: vi.fn(),
},
writable: true,
});
const environmentId = "test-env-id";
const webAppUrl = "https://app.formbricks.com";
const environment = { id: environmentId } as TEnvironment;
const surveys: TSurvey[] = [];
const databases = [];
const locale = "en-US" as const;
const mockNotionIntegration: TIntegrationNotion = {
id: "int-notion-123",
type: "notion",
environmentId: environmentId,
config: {
key: { access_token: "test-token" } as TIntegrationNotionCredential,
data: [],
},
};
const baseProps = {
environment,
surveys,
databasesArray: databases, // Renamed databases to databasesArray to match component prop
webAppUrl,
locale,
};
describe("NotionWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration disabled when enabled is false", () => {
// Changed description slightly
render(<NotionWrapper {...baseProps} enabled={false} notionIntegration={undefined} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration enabled when enabled is true and not connected (no integration)", () => {
// Changed description slightly
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("renders ConnectIntegration enabled when enabled is true and not connected (integration without key)", () => {
// Changed description slightly
const integrationWithoutKey = {
...mockNotionIntegration,
config: { data: [] },
} as unknown as TIntegrationNotion;
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={integrationWithoutKey} />); // Changed isEnabled to enabled
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
});
test("calls authorize and redirects when Connect button is clicked", async () => {
const mockAuthorize = vi.mocked(authorize);
const redirectUrl = "https://notion.com/auth";
mockAuthorize.mockResolvedValue(redirectUrl);
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
const connectButton = screen.getByRole("button", { name: "Connect" });
await userEvent.click(connectButton);
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
});
});
});

View File

@@ -1,58 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { authorize } from "./notion";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/v1/integrations/notion`;
const expectedHeaders = { environmentId: environmentId };
afterEach(() => {
vi.clearAllMocks();
});
test("should return authUrl on successful fetch", async () => {
const mockAuthUrl = "https://api.notion.com/v1/oauth/authorize?...";
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(authUrl).toBe(mockAuthUrl);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw error and log on failed fetch", async () => {
const errorText = "Failed to fetch";
mockFetch.mockResolvedValueOnce({
ok: false,
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
headers: expectedHeaders,
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch notion config");
});
});

View File

@@ -1,50 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
// Mock child components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, className }: { children: React.ReactNode; className: string }) => (
<button className={className}>{children}</button>
),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: () => <div data-testid="go-back-button">Go Back</div>,
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key, // Simple mock translation
}),
}));
describe("Notion Integration Loading", () => {
afterEach(() => {
cleanup();
});
test("renders loading state correctly", () => {
render(<Loading />);
// Check for GoBackButton mock
expect(screen.getByTestId("go-back-button")).toBeInTheDocument();
// Check for the disabled button
const linkButton = screen.getByText("environments.integrations.notion.link_database");
expect(linkButton).toBeInTheDocument();
expect(linkButton.closest("button")).toHaveClass(
"pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"
);
// Check for table headers
expect(screen.getByText("common.survey")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.database_name")).toBeInTheDocument();
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
// Check for placeholder elements (skeleton loaders)
// There should be 3 rows * 5 pulse divs per row = 15 pulse divs
const pulseDivs = screen.getAllByText("", { selector: "div.animate-pulse" });
expect(pulseDivs.length).toBeGreaterThanOrEqual(15); // Check if at least 15 pulse divs are rendered
});
});

View File

@@ -1,250 +0,0 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import Page from "@/app/(app)/environments/[environmentId]/integrations/notion/page";
import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper", () => ({
NotionWrapper: vi.fn(
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
<div>
<span>Mocked NotionWrapper</span>
<span data-testid="enabled">{enabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{notionIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
let mockNotionClientId: string | undefined = "test-client-id";
let mockNotionClientSecret: string | undefined = "test-client-secret";
let mockNotionAuthUrl: string | undefined = "https://notion.com/auth";
let mockNotionRedirectUri: string | undefined = "https://app.formbricks.com/redirect";
vi.mock("@/lib/constants", () => ({
get NOTION_OAUTH_CLIENT_ID() {
return mockNotionClientId;
},
get NOTION_OAUTH_CLIENT_SECRET() {
return mockNotionClientSecret;
},
get NOTION_AUTH_URL() {
return mockNotionAuthUrl;
},
get NOTION_REDIRECT_URI() {
return mockNotionRedirectUri;
},
WEBAPP_URL: "test-webapp-url",
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrationByType: vi.fn(),
}));
vi.mock("@/lib/notion/service", () => ({
getNotionDatabases: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
type: "development",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockNotionIntegration = {
id: "integration1",
type: "notion",
config: {
data: [],
key: { bot_id: "bot-id-123" },
email: "test@example.com",
},
} as unknown as TIntegrationNotion;
const mockDatabases: TIntegrationNotionDatabase[] = [
{ id: "db1", name: "Database 1", properties: {} },
{ id: "db2", name: "Database 2", properties: {} },
];
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("NotionIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
} as TEnvironmentAuth);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrationByType).mockResolvedValue(mockNotionIntegration);
vi.mocked(getNotionDatabases).mockResolvedValue(mockDatabases);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
mockNotionClientId = "test-client-id";
mockNotionClientSecret = "test-client-secret";
mockNotionAuthUrl = "https://notion.com/auth";
mockNotionRedirectUri = "https://app.formbricks.com/redirect";
});
test("renders the page with NotionWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("environments.integrations.notion.notion_integration")).toBeInTheDocument();
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockNotionIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
} as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
test("passes enabled=false to NotionWrapper when constants are missing", async () => {
mockNotionClientId = undefined; // Simulate missing constant
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("enabled")).toHaveTextContent("false");
});
test("handles case where no Notion integration exists", async () => {
vi.mocked(getIntegrationByType).mockResolvedValue(null); // No integration
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
});
test("handles case where integration exists but has no key (bot_id)", async () => {
const integrationWithoutKey = {
...mockNotionIntegration,
config: { ...mockNotionIntegration.config, key: undefined },
} as unknown as TIntegrationNotion;
vi.mocked(getIntegrationByType).mockResolvedValue(integrationWithoutKey);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
expect(screen.getByTestId("integrationId")).toHaveTextContent(integrationWithoutKey.id);
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
});
});

View File

@@ -1,243 +0,0 @@
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook";
import Page from "@/app/(app)/environments/[environmentId]/integrations/page";
import { getIntegrations } from "@/lib/integration/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegration } from "@formbricks/types/integration";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/webhook", () => ({
getWebhookCountBySource: vi.fn(),
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrations: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/integration-card", () => ({
Card: ({ label, description, statusText, disabled }) => (
<div data-testid={`card-${label}`}>
<h1>{label}</h1>
<p>{description}</p>
<span>{statusText}</span>
{disabled && <span>Disabled</span>}
</div>
),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div>{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ pageTitle }) => <h1>{pageTitle}</h1>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }) => <img alt={alt} />,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: true,
} as unknown as TEnvironment;
const mockIntegrations: TIntegration[] = [
{
id: "google-sheets-id",
type: "googleSheets",
environmentId: "test-env-id",
config: { data: [], email: "test@example.com" } as unknown as TIntegration["config"],
},
{
id: "slack-id",
type: "slack",
environmentId: "test-env-id",
config: { data: [] } as unknown as TIntegration["config"],
},
];
const mockParams = { environmentId: "test-env-id" };
const mockProps = { params: mockParams };
describe("Integrations Page", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getWebhookCountBySource).mockResolvedValue(0);
vi.mocked(getIntegrations).mockResolvedValue([]);
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
isBilling: false,
} as unknown as TEnvironmentAuth);
});
test("renders the page header and integration cards", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "zapier") return 1;
if (source === "user") return 2;
return 0;
});
vi.mocked(getIntegrations).mockResolvedValue(mockIntegrations);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("common.integrations")).toBeInTheDocument(); // Page Header
expect(screen.getByTestId("card-Javascript SDK")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.website_or_app_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[0]).toBeInTheDocument(); // JS SDK status
expect(screen.getByTestId("card-Zapier")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.zapier_integration_description")).toBeInTheDocument();
expect(screen.getByText("1 zap")).toBeInTheDocument(); // Zapier status
expect(screen.getByTestId("card-Webhooks")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.webhook_integration_description")).toBeInTheDocument();
expect(screen.getByText("2 webhooks")).toBeInTheDocument(); // Webhook status
expect(screen.getByTestId("card-Google Sheets")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.google_sheet_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[1]).toBeInTheDocument(); // Google Sheets status
expect(screen.getByTestId("card-Airtable")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.airtable_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[0]).toBeInTheDocument(); // Airtable status
expect(screen.getByTestId("card-Slack")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.slack_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.connected")[2]).toBeInTheDocument(); // Slack status
expect(screen.getByTestId("card-n8n")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.n8n_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[1]).toBeInTheDocument(); // n8n status
expect(screen.getByTestId("card-Make.com")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.make_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[2]).toBeInTheDocument(); // Make status
expect(screen.getByTestId("card-Notion")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion_integration_description")).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[3]).toBeInTheDocument(); // Notion status
expect(screen.getByTestId("card-Activepieces")).toBeInTheDocument();
expect(
screen.getByText("environments.integrations.activepieces_integration_description")
).toBeInTheDocument();
expect(screen.getAllByText("common.not_connected")[4]).toBeInTheDocument(); // Activepieces status
});
test("renders disabled cards when isReadOnly is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: true,
isBilling: false,
} as unknown as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
// JS SDK and Webhooks should not be disabled
expect(screen.getByTestId("card-Javascript SDK")).not.toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Webhooks")).not.toHaveTextContent("Disabled");
// Other cards should be disabled
expect(screen.getByTestId("card-Zapier")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Google Sheets")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Airtable")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Slack")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-n8n")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Notion")).toHaveTextContent("Disabled");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("Disabled");
});
test("redirects when isBilling is true", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
isReadOnly: false,
isBilling: true,
} as unknown as TEnvironmentAuth);
await Page(mockProps);
expect(vi.mocked(redirect)).toHaveBeenCalledWith(
`/environments/${mockParams.environmentId}/settings/billing`
);
});
test("renders correct status text for single integration", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "n8n") return 1;
if (source === "make") return 1;
if (source === "activepieces") return 1;
return 0;
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-n8n")).toHaveTextContent("1 common.integration");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("1 common.integration");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("1 common.integration");
});
test("renders correct status text for multiple integrations", async () => {
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
if (source === "n8n") return 3;
if (source === "make") return 4;
if (source === "activepieces") return 5;
return 0;
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-n8n")).toHaveTextContent("3 common.integrations");
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("4 common.integrations");
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("5 common.integrations");
});
test("renders not connected status when widgetSetupCompleted is false", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: { ...mockEnvironment, appSetupCompleted: false },
isReadOnly: false,
isBilling: false,
} as unknown as TEnvironmentAuth);
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByTestId("card-Javascript SDK")).toHaveTextContent("common.not_connected");
});
});

View File

@@ -1,750 +0,0 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationSlack,
TIntegrationSlackConfigData,
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { AddChannelMappingModal } from "./AddChannelMappingModal";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
AdditionalIntegrationSettings: ({
includeVariables,
setIncludeVariables,
includeHiddenFields,
setIncludeHiddenFields,
includeMetadata,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: any) => (
<div>
<span>Additional Settings</span>
<input
data-testid="include-variables"
type="checkbox"
checked={includeVariables}
onChange={(e) => setIncludeVariables(e.target.checked)}
/>
<input
data-testid="include-hidden-fields"
type="checkbox"
checked={includeHiddenFields}
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
/>
<input
data-testid="include-metadata"
type="checkbox"
checked={includeMetadata}
onChange={(e) => setIncludeMetadata(e.target.checked)}
/>
<input
data-testid="include-created-at"
type="checkbox"
checked={includeCreatedAt}
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
/>
</div>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, disabled }: any) => (
<div>
<label>{label}</label>
<select
data-testid={label.includes("channel") ? "channel-dropdown" : "survey-dropdown"}
value={selectedItem?.id || ""}
onChange={(e) => {
const selected = items.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">Select...</option>
{items.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
),
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
callback();
},
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string, _?: any) => {
// NOSONAR
// Simple mock translation function
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "environments.integrations.slack.select_channel") return "Select channel";
if (key === "common.select_survey") return "Select survey";
if (key === "common.questions") return "Questions";
if (key === "environments.integrations.slack.please_select_a_channel")
return "Please select a channel.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.select_at_least_one_question_error")
return "Please select at least one question.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
if (key === "environments.integrations.slack.dont_see_your_channel") return "Don't see your channel?";
if (key === "common.note") return "Note";
if (key === "environments.integrations.slack.already_connected_another_survey")
return "This channel is already connected to another survey.";
if (key === "environments.integrations.slack.create_at_least_one_channel_error")
return "Please create at least one channel in Slack first.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.slack.link_channel") return "Link Channel";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
vi.mock("lucide-react", () => ({
CircleHelpIcon: () => <div data-testid="circle-help-icon" />,
Check: () => <div data-testid="check-icon" />, // Add the Check icon mock
Loader2: () => <div data-testid="loader-icon" />, // Add the Loader2 icon mock
}));
// Mock dependencies
const createOrUpdateIntegrationActionMock = vi.mocked(createOrUpdateIntegrationAction);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2?" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "c2", label: { default: "Choice 2" } },
],
},
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate this?" },
required: true,
scale: "number",
range: 5,
} as unknown as TSurveyQuestion,
],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
variables: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const channels: TIntegrationItem[] = [
{ id: "channel1", name: "#general" },
{ id: "channel2", name: "#random" },
];
const mockSlackIntegration: TIntegrationSlack = {
id: "integration1",
type: "slack",
environmentId: environmentId,
config: {
key: {
access_token: "xoxb-test-token",
team_name: "Test Team",
team_id: "T123",
} as unknown as TIntegrationSlackCredential,
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationSlackConfigData & { index: number } = {
channelId: channels[0].id,
channelName: channels[0].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: [surveys[0].questions[0].id],
questions: "Selected questions",
createdAt: new Date(),
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: false,
index: 0,
};
describe("AddChannelMappingModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockSlackIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Link Channel" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
expect(screen.getByText("Don't see your channel?")).toBeInTheDocument();
});
test("renders correctly when open (update mode)", () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByText("Update")).toBeInTheDocument();
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
expect(screen.getByTestId("include-variables")).toBeChecked();
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
expect(screen.getByTestId("include-metadata")).toBeChecked();
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
});
test("selects survey and shows questions", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
expect(screen.getByText("Questions")).toBeInTheDocument();
surveys[1].questions.forEach((q) => {
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
// Initially all questions should be checked when a survey is selected in create mode
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
});
});
test("handles question selection", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
await userEvent.click(firstQuestionCheckbox);
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
});
test("creates integration successfully", async () => {
createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any }); // Mock successful action
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={{ ...mockSlackIntegration, config: { ...mockSlackIntegration.config, data: [] } }} // Start with empty data
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[1].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Wait for questions to appear and potentially uncheck one
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
// Check additional settings
await userEvent.click(screen.getByTestId("include-variables"));
await userEvent.click(screen.getByTestId("include-metadata"));
await userEvent.click(submitButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
type: "slack",
config: expect.objectContaining({
key: mockSlackIntegration.config.key,
data: expect.arrayContaining([
expect.objectContaining({
channelId: channels[1].id,
channelName: channels[1].name,
surveyId: surveys[0].id,
surveyName: surveys[0].name,
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
questions: "Selected questions",
includeVariables: true,
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: true, // Default
}),
]),
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("deletes integration successfully", async () => {
createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any });
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration} // Contains initial data at index 0
channels={channels}
selectedIntegration={mockSelectedIntegration}
/>
);
const deleteButton = screen.getByText("Delete");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({
environmentId,
integrationData: expect.objectContaining({
config: expect.objectContaining({
data: [], // Data array should be empty after deletion
}),
}),
});
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
});
await waitFor(() => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("shows validation error if no channel selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// No channel selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a channel.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no survey selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
// No survey selected
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows validation error if no questions selected", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
// Uncheck all questions
for (const question of surveys[0].questions) {
const checkbox = await screen.findByLabelText(question.headline.default);
await userEvent.click(checkbox);
}
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
});
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
const errorMessage = "Failed to update integration";
createOrUpdateIntegrationActionMock.mockRejectedValue(new Error(errorMessage));
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const surveyDropdown = screen.getByTestId("survey-dropdown");
const submitButton = screen.getByRole("button", { name: "Link Channel" });
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
await userEvent.click(submitButton);
await waitFor(() => {
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls setOpen(false) and resets form on cancel", async () => {
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
const cancelButton = screen.getByText("Cancel");
// Simulate some interaction
await userEvent.selectOptions(channelDropdown, channels[0].id);
await userEvent.click(cancelButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Re-render with open=true to check if state was reset (channel should be unselected)
cleanup();
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={mockSlackIntegration}
channels={channels}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("channel-dropdown")).toHaveValue("");
});
test("shows warning when selected channel is already connected (add mode)", async () => {
// Add an existing connection for channel1
const integrationWithExisting = {
...mockSlackIntegration,
config: {
...mockSlackIntegration.config,
data: [
{
channelId: "channel1",
channelName: "#general",
surveyId: "survey-other",
surveyName: "Other Survey",
questionIds: ["q-other"],
questions: "All questions",
createdAt: new Date(),
} as TIntegrationSlackConfigData,
],
},
};
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={integrationWithExisting}
channels={channels}
selectedIntegration={null} // Add mode
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
await userEvent.selectOptions(channelDropdown, "channel1");
expect(screen.getByText("This channel is already connected to another survey.")).toBeInTheDocument();
});
test("does not show warning when selected channel is the one being edited", async () => {
// Edit the existing connection for channel1
const integrationToEdit = {
...mockSlackIntegration,
config: {
...mockSlackIntegration.config,
data: [
{
channelId: "channel1",
channelName: "#general",
surveyId: "survey1",
surveyName: "Survey 1",
questionIds: ["q1"],
questions: "Selected questions",
createdAt: new Date(),
index: 0,
} as TIntegrationSlackConfigData & { index: number },
],
},
};
const selectedIntegrationForEdit = integrationToEdit.config.data[0];
render(
<AddChannelMappingModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
slackIntegration={integrationToEdit}
channels={channels}
selectedIntegration={selectedIntegrationForEdit} // Edit mode
/>
);
const channelDropdown = screen.getByTestId("channel-dropdown");
// Channel is already selected via selectedIntegration prop
expect(channelDropdown).toHaveValue("channel1");
expect(
screen.queryByText("This channel is already connected to another survey.")
).not.toBeInTheDocument();
});
});

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