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
167 changed files with 3869 additions and 11557 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,9 +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)
# DEFAULT_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=
# Send new users to Brevo
# BREVO_API_KEY=
@@ -217,4 +224,4 @@ UNKEY_ROOT_KEY=
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT=1
# DISABLE_USER_MANAGEMENT

View File

@@ -14,8 +14,7 @@ When generating test files inside the "/app/web" path, follow these rules:
- 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:

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

@@ -20,7 +20,7 @@ jobs:
with:
egress-policy: audit
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481

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.31.1",
"@typescript-eslint/parser": "8.31.1",
"@vitejs/plugin-react": "4.4.1",
"@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

@@ -84,8 +84,90 @@ 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
COPY --from=installer /app/apps/web/.next/standalone ./
RUN chown -R nextjs:nextjs ./ && chmod -R 755 ./
RUN chown -R nextjs:nextjs ./ && chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json .
RUN chmod 644 ./package.json
COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static
RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.next/static
COPY --from=installer /app/apps/web/public ./apps/web/public
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
# USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js

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

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

@@ -33,7 +33,7 @@ const Loading = () => {
</div>
</div>
</div>
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>

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,298 +0,0 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { 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", () => ({
getEnterpriseLicense: vi.fn(),
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(getEnterpriseLicense).mockResolvedValue(mockLicense);
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 () => {
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...mockLicense,
isPendingDowngrade: false,
active: false,
});
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(); // This should now pass
});
test("renders DevEnvironmentBanner in development environment", async () => {
const devEnvironment = { ...mockEnvironment, type: "development" as const };
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
mockIsDevelopment = true;
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;
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 () => {
// Ensure the license mock reflects the condition needed for the banner
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
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);
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);
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);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.environment_not_found"
);
});
test("throws error if projects, environments or organizations not found", async () => {
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
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);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.project_permission_not_found"
);
});
});

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

@@ -26,6 +26,13 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("../../components/FormbricksClient", () => ({
FormbricksClient: ({ userId, email }: any) => (
<div data-testid="FormbricksClient">
{userId}-{email}
</div>
),
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
}));

View File

@@ -1,5 +1,6 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -36,6 +37,7 @@ export const DeleteAccount = ({
setOpen={setModalOpen}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
formbricksLogout={formbricksLogout}
organizationsWithSingleOwner={organizationsWithSingleOwner}
/>
<p className="text-sm text-slate-700">

View File

@@ -409,7 +409,7 @@ export const getQuestionSummary = async (
}
});
Object.entries(choiceCountMap).forEach(([label, count]) => {
Object.entries(choiceCountMap).map(([label, count]) => {
values.push({
value: label,
count,
@@ -508,7 +508,7 @@ export const getQuestionSummary = async (
}
});
Object.entries(choiceCountMap).forEach(([label, count]) => {
Object.entries(choiceCountMap).map(([label, count]) => {
values.push({
rating: parseInt(label),
count,

View File

@@ -36,10 +36,14 @@ vi.mock("@/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_API_HOST: "mock-formbricks-api-host",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
IntercomClientWrapper: () => <div data-testid="mock-intercom-wrapper" />,
}));
@@ -70,5 +74,17 @@ describe("(app) AppLayout", () => {
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
});
test("skips FormbricksClient if no user is present", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const element = await AppLayout({
children: <div data-testid="child-content">Hello from children</div>,
});
render(element);
expect(screen.queryByTestId("formbricks-client")).not.toBeInTheDocument();
});
});

View File

@@ -1,5 +1,13 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
import {
FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID,
IS_FORMBRICKS_ENABLED,
IS_POSTHOG_CONFIGURED,
POSTHOG_API_HOST,
POSTHOG_API_KEY,
} from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -30,6 +38,15 @@ const AppLayout = async ({ children }) => {
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
{user ? (
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
) : null}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}

View File

@@ -1,450 +0,0 @@
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service";
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { writeData as writeNotionData } from "@/lib/notion/service";
import { processResponseData } from "@/lib/responses";
import { writeDataToSlack } from "@/lib/slack/service";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import {
TIntegrationAirtable,
TIntegrationAirtableConfig,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
} from "@formbricks/types/integration/airtable";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfig,
TIntegrationGoogleSheetsConfigData,
TIntegrationGoogleSheetsCredential,
} from "@formbricks/types/integration/google-sheet";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionCredential,
} from "@formbricks/types/integration/notion";
import {
TIntegrationSlack,
TIntegrationSlackConfigData,
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { handleIntegrations } from "./handleIntegrations";
// Mock dependencies
vi.mock("@/lib/airtable/service");
vi.mock("@/lib/googleSheet/service");
vi.mock("@/lib/i18n/utils");
vi.mock("@/lib/notion/service");
vi.mock("@/lib/responses");
vi.mock("@/lib/slack/service");
vi.mock("@/lib/utils/datetime");
vi.mock("@/lib/utils/recall");
vi.mock("@/lib/utils/strings");
vi.mock("@formbricks/logger");
// Mock data
const surveyId = "survey1";
const questionId1 = "q1";
const questionId2 = "q2";
const questionId3 = "q3_picture";
const hiddenFieldId = "hidden1";
const variableId = "var1";
const mockPipelineInput = {
environmentId: "env1",
surveyId: surveyId,
response: {
id: "response1",
createdAt: new Date("2024-01-01T12:00:00Z"),
updatedAt: new Date("2024-01-01T12:00:00Z"),
finished: true,
surveyId: surveyId,
data: {
[questionId1]: "Answer 1",
[questionId2]: ["Choice 1", "Choice 2"],
[questionId3]: ["picChoice1"],
[hiddenFieldId]: "Hidden Value",
},
meta: {
url: "http://example.com",
source: "web",
userAgent: {
browser: "Chrome",
os: "Mac OS",
device: "Desktop",
},
country: "USA",
action: "Action Name",
} as TResponseMeta,
personAttributes: {},
singleUseId: null,
personId: "person1",
notes: [],
tags: [],
variables: {
[variableId]: "Variable Value",
},
ttc: {},
} as unknown as TResponse,
} as TPipelineInput;
const mockSurvey = {
id: surveyId,
name: "Test Survey",
questions: [
{
id: questionId1,
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1 {{recall:q2}}" },
required: true,
} as unknown as TSurveyOpenTextQuestion,
{
id: questionId2,
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
],
},
{
id: questionId3,
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Question 3" },
required: true,
choices: [
{ id: "picChoice1", imageUrl: "http://image.com/1" },
{ id: "picChoice2", imageUrl: "http://image.com/2" },
],
} as unknown as TSurveyPictureSelectionQuestion,
],
hiddenFields: {
enabled: true,
fieldIds: [hiddenFieldId],
},
variables: [{ id: variableId, name: "Variable 1" } as unknown as TSurvey["variables"][0]],
autoClose: null,
triggers: [],
status: "inProgress",
type: "app",
languages: [],
styling: {},
segment: null,
recontactDays: null,
autoComplete: null,
closeOnDate: null,
createdAt: new Date(),
updatedAt: new Date(),
displayOption: "displayOnce",
displayPercentage: null,
environmentId: "env1",
singleUse: null,
surveyClosedMessage: null,
resultShareKey: null,
pin: null,
} as unknown as TSurvey;
const mockAirtableIntegration: TIntegrationAirtable = {
id: "int_airtable",
type: "airtable",
environmentId: "env1",
config: {
key: { access_token: "airtable_key" } as TIntegrationAirtableCredential,
data: [
{
surveyId: surveyId,
questionIds: [questionId1, questionId2],
baseId: "base1",
tableId: "table1",
createdAt: new Date(),
includeHiddenFields: true,
includeMetadata: true,
includeCreatedAt: true,
includeVariables: true,
} as TIntegrationAirtableConfigData,
],
} as TIntegrationAirtableConfig,
};
const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
id: "int_gsheets",
type: "googleSheets",
environmentId: "env1",
config: {
key: { refresh_token: "gsheet_key" } as TIntegrationGoogleSheetsCredential,
data: [
{
surveyId: surveyId,
spreadsheetId: "sheet1",
spreadsheetName: "Sheet Name",
questionIds: [questionId1],
questions: "What is Q1?",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
includeHiddenFields: false,
includeMetadata: false,
includeCreatedAt: false,
includeVariables: false,
} as TIntegrationGoogleSheetsConfigData,
],
} as TIntegrationGoogleSheetsConfig,
};
const mockSlackIntegration: TIntegrationSlack = {
id: "int_slack",
type: "slack",
environmentId: "env1",
config: {
key: { access_token: "slack_key", app_id: "A1" } as TIntegrationSlackCredential,
data: [
{
surveyId: surveyId,
channelId: "channel1",
channelName: "Channel 1",
questionIds: [questionId1, questionId2, questionId3],
questions: "Q1, Q2, Q3",
createdAt: new Date(),
includeHiddenFields: true,
includeMetadata: true,
includeCreatedAt: true,
includeVariables: true,
} as TIntegrationSlackConfigData,
],
},
};
const mockNotionIntegration: TIntegrationNotion = {
id: "int_notion",
type: "notion",
environmentId: "env1",
config: {
key: {
access_token: "notion_key",
workspace_name: "ws",
workspace_icon: "",
workspace_id: "w1",
} as TIntegrationNotionCredential,
data: [
{
surveyId: surveyId,
databaseId: "db1",
databaseName: "DB 1",
mapping: [
{
question: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
column: { id: "col1", name: "Column 1", type: "rich_text" },
},
{
question: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
column: { id: "col3", name: "Column 3", type: "url" },
},
{
question: { id: "metadata", name: "Metadata", type: "metadata" },
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
},
{
question: { id: "createdAt", name: "Created At", type: "createdAt" },
column: { id: "col_created", name: "Created Col", type: "date" },
},
],
createdAt: new Date(),
} as TIntegrationNotionConfigData,
],
},
};
describe("handleIntegrations", () => {
beforeEach(() => {
vi.resetAllMocks();
// Refine mock to explicitly handle string inputs
vi.mocked(processResponseData).mockImplementation((data) => {
if (typeof data === "string") {
return data; // Directly return string inputs
}
// Handle arrays and null/undefined as before
return String(Array.isArray(data) ? data.join(", ") : (data ?? ""));
});
vi.mocked(getLocalizedValue).mockImplementation((value, _) => value?.default || "");
vi.mocked(parseRecallInfo).mockImplementation((text, _, __) => text || "");
vi.mocked(getFormattedDateTimeString).mockReturnValue("2024-01-01 12:00");
vi.mocked(truncateText).mockImplementation((text, limit) => text.slice(0, limit));
});
afterEach(() => {
vi.clearAllMocks();
});
test("should call correct handlers for each integration type", async () => {
const integrations = [
mockAirtableIntegration,
mockGoogleSheetsIntegration,
mockSlackIntegration,
mockNotionIntegration,
];
vi.mocked(airtableWriteData).mockResolvedValue(undefined);
vi.mocked(googleSheetWriteData).mockResolvedValue(undefined);
vi.mocked(writeDataToSlack).mockResolvedValue(undefined);
vi.mocked(writeNotionData).mockResolvedValue(undefined);
await handleIntegrations(integrations, mockPipelineInput, mockSurvey);
expect(airtableWriteData).toHaveBeenCalledTimes(1);
expect(googleSheetWriteData).toHaveBeenCalledTimes(1);
expect(writeDataToSlack).toHaveBeenCalledTimes(1);
expect(writeNotionData).toHaveBeenCalledTimes(1);
expect(logger.error).not.toHaveBeenCalled();
});
test("should log errors when integration handlers fail", async () => {
const integrations = [mockAirtableIntegration, mockSlackIntegration];
const airtableError = new Error("Airtable failed");
const slackError = new Error("Slack failed");
vi.mocked(airtableWriteData).mockRejectedValue(airtableError);
vi.mocked(writeDataToSlack).mockRejectedValue(slackError);
await handleIntegrations(integrations, mockPipelineInput, mockSurvey);
expect(airtableWriteData).toHaveBeenCalledTimes(1);
expect(writeDataToSlack).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(airtableError, "Error in airtable integration");
expect(logger.error).toHaveBeenCalledWith(slackError, "Error in slack integration");
});
test("should handle empty integrations array", async () => {
await handleIntegrations([], mockPipelineInput, mockSurvey);
expect(airtableWriteData).not.toHaveBeenCalled();
expect(googleSheetWriteData).not.toHaveBeenCalled();
expect(writeDataToSlack).not.toHaveBeenCalled();
expect(writeNotionData).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});
// Test individual handlers by calling the main function with a single integration
describe("Airtable Integration", () => {
test("should call airtableWriteData with correct parameters", async () => {
vi.mocked(airtableWriteData).mockResolvedValue(undefined);
await handleIntegrations([mockAirtableIntegration], mockPipelineInput, mockSurvey);
expect(airtableWriteData).toHaveBeenCalledTimes(1);
// Adjust expectations for metadata and recalled question
const expectedMetadataString =
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
expect(airtableWriteData).toHaveBeenCalledWith(
mockAirtableIntegration.config.key,
mockAirtableIntegration.config.data[0],
[
[
"Answer 1",
"Choice 1, Choice 2",
"Hidden Value",
expectedMetadataString,
"Variable Value",
"2024-01-01 12:00",
], // responses + hidden + meta + var + created
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"], // questions (raw headline for Airtable) + hidden + meta + var + created
]
);
});
test("should not call airtableWriteData if surveyId does not match", async () => {
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
await handleIntegrations([mockAirtableIntegration], differentSurveyInput, mockSurvey);
expect(airtableWriteData).not.toHaveBeenCalled();
});
test("should return error result on failure", async () => {
const error = new Error("Airtable API error");
vi.mocked(airtableWriteData).mockRejectedValue(error);
await handleIntegrations([mockAirtableIntegration], mockPipelineInput, mockSurvey);
// Verify error was logged, remove checks on the return value
expect(logger.error).toHaveBeenCalledWith(error, "Error in airtable integration");
});
});
describe("Google Sheets Integration", () => {
test("should call googleSheetWriteData with correct parameters", async () => {
vi.mocked(googleSheetWriteData).mockResolvedValue(undefined);
await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey);
expect(googleSheetWriteData).toHaveBeenCalledTimes(1);
// Check that createdAt is converted to Date object
const expectedIntegrationData = structuredClone(mockGoogleSheetsIntegration);
expectedIntegrationData.config.data[0].createdAt = new Date(
mockGoogleSheetsIntegration.config.data[0].createdAt
);
expect(googleSheetWriteData).toHaveBeenCalledWith(
expectedIntegrationData,
mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
[
["Answer 1"], // responses
["Question 1 {{recall:q2}}"], // questions (raw headline for Google Sheets)
]
);
});
test("should not call googleSheetWriteData if surveyId does not match", async () => {
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
await handleIntegrations([mockGoogleSheetsIntegration], differentSurveyInput, mockSurvey);
expect(googleSheetWriteData).not.toHaveBeenCalled();
});
test("should return error result on failure", async () => {
const error = new Error("Google Sheets API error");
vi.mocked(googleSheetWriteData).mockRejectedValue(error);
await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey);
// Verify error was logged, remove checks on the return value
expect(logger.error).toHaveBeenCalledWith(error, "Error in google sheets integration");
});
});
describe("Slack Integration", () => {
test("should not call writeDataToSlack if surveyId does not match", async () => {
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
await handleIntegrations([mockSlackIntegration], differentSurveyInput, mockSurvey);
expect(writeDataToSlack).not.toHaveBeenCalled();
});
test("should return error result on failure", async () => {
const error = new Error("Slack API error");
vi.mocked(writeDataToSlack).mockRejectedValue(error);
await handleIntegrations([mockSlackIntegration], mockPipelineInput, mockSurvey);
// Verify error was logged, remove checks on the return value
expect(logger.error).toHaveBeenCalledWith(error, "Error in slack integration");
});
});
describe("Notion Integration", () => {
test("should not call writeNotionData if surveyId does not match", async () => {
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
await handleIntegrations([mockNotionIntegration], differentSurveyInput, mockSurvey);
expect(writeNotionData).not.toHaveBeenCalled();
});
test("should return error result on failure", async () => {
const error = new Error("Notion API error");
vi.mocked(writeNotionData).mockRejectedValue(error);
await handleIntegrations([mockNotionIntegration], mockPipelineInput, mockSurvey);
// Verify error was logged, remove checks on the return value
expect(logger.error).toHaveBeenCalledWith(error, "Error in notion integration");
});
});
});

View File

@@ -8,14 +8,14 @@ import {
mockResponseWithContactQuestion,
mockSurvey,
mockSurveyWithContactQuestion,
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
} from "@/app/api/(internal)/pipeline/lib/tests/__mocks__/survey-follow-up.mock";
import { sendFollowUpEmail } from "@/modules/email";
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
import { evaluateFollowUp, sendSurveyFollowUps } from "../survey-follow-up";
// Mock dependencies
vi.mock("@/modules/email", () => ({

View File

@@ -2,9 +2,8 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponse, updateResponse } from "@/lib/response/service";
import { updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
@@ -49,9 +48,10 @@ export const PUT = async (
);
}
// update response
let response;
try {
response = await getResponse(responseId);
response = await updateResponse(responseId, inputValidation.data);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
@@ -66,64 +66,27 @@ export const PUT = async (
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
if (!validateFileUploads(response.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions,
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
// update response
let updatedResponse;
try {
updatedResponse = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
}
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
response,
});
if (updatedResponse.finished) {
if (response.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: updatedResponse,
response,
});
}
return responses.successResponse({}, true);

View File

@@ -1,77 +0,0 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { doesContactExist } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
// Mock cache module
vi.mock("@/lib/cache");
vi.mock("@/lib/cache/contact", () => ({
contactCache: {
tag: {
byId: vi.fn((id) => `contact-${id}`),
},
},
}));
// Mock react cache
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock react's cache to just return the function
};
});
const contactId = "test-contact-id";
describe("doesContactExist", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return true if contact exists", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: contactId });
const result = await doesContactExist(contactId);
expect(result).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
select: { id: true },
});
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`doesContactExistDisplaysApiV2-${contactId}`], {
tags: [contactCache.tag.byId(contactId)],
});
});
test("should return false if contact does not exist", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await doesContactExist(contactId);
expect(result).toBe(false);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: { id: contactId },
select: { id: true },
});
expect(cache).toHaveBeenCalledWith(expect.any(Function), [`doesContactExistDisplaysApiV2-${contactId}`], {
tags: [contactCache.tag.byId(contactId)],
});
});
});

View File

@@ -1,178 +0,0 @@
import { displayCache } from "@/lib/display/cache";
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 { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
import { createDisplay } from "./display";
// Mock dependencies
vi.mock("@/lib/display/cache", () => ({
displayCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs) => inputs.map((input) => input[0])), // Pass through validation for testing
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
create: vi.fn(),
},
},
}));
vi.mock("./contact", () => ({
doesContactExist: vi.fn(),
}));
const environmentId = "test-env-id";
const surveyId = "test-survey-id";
const contactId = "test-contact-id";
const displayId = "test-display-id";
const displayInput: TDisplayCreateInputV2 = {
environmentId,
surveyId,
contactId,
};
const displayInputWithoutContact: TDisplayCreateInputV2 = {
environmentId,
surveyId,
};
const mockDisplay = {
id: displayId,
contactId,
surveyId,
};
const mockDisplayWithoutContact = {
id: displayId,
contactId: null,
surveyId,
};
describe("createDisplay", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should create a display with contactId successfully", async () => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplay);
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
contact: { connect: { id: contactId } },
},
select: { id: true, contactId: true, surveyId: true },
});
expect(displayCache.revalidate).toHaveBeenCalledWith({
id: displayId,
contactId,
surveyId,
environmentId,
});
expect(result).toEqual(mockDisplay); // Changed this line
});
test("should create a display without contactId successfully", async () => {
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact);
const result = await createDisplay(displayInputWithoutContact);
expect(validateInputs).toHaveBeenCalledWith([displayInputWithoutContact, expect.any(Object)]);
expect(doesContactExist).not.toHaveBeenCalled();
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
},
select: { id: true, contactId: true, surveyId: true },
});
expect(displayCache.revalidate).toHaveBeenCalledWith({
id: displayId,
contactId: null,
surveyId,
environmentId,
});
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
});
test("should create a display even if contact does not exist", async () => {
vi.mocked(doesContactExist).mockResolvedValue(false);
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
const result = await createDisplay(displayInput);
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
expect(doesContactExist).toHaveBeenCalledWith(contactId);
expect(prisma.display.create).toHaveBeenCalledWith({
data: {
survey: { connect: { id: surveyId } },
// No contact connection expected here
},
select: { id: true, contactId: true, surveyId: true },
});
expect(displayCache.revalidate).toHaveBeenCalledWith({
id: displayId,
contactId: null, // Assuming prisma returns null if contact wasn't connected
surveyId,
environmentId,
});
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
});
test("should throw ValidationError if validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(createDisplay(displayInput)).rejects.toThrow(ValidationError);
expect(doesContactExist).not.toHaveBeenCalled();
expect(prisma.display.create).not.toHaveBeenCalled();
expect(displayCache.revalidate).not.toHaveBeenCalled();
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "2.0.0",
});
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(prismaError);
await expect(createDisplay(displayInput)).rejects.toThrow(DatabaseError);
expect(displayCache.revalidate).not.toHaveBeenCalled();
});
test("should throw original error on other errors during creation", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.display.create).mockRejectedValue(genericError);
await expect(createDisplay(displayInput)).rejects.toThrow(genericError);
expect(displayCache.revalidate).not.toHaveBeenCalled();
});
test("should throw original error if doesContactExist fails", async () => {
const contactCheckError = new Error("Failed to check contact");
vi.mocked(doesContactExist).mockRejectedValue(contactCheckError);
await expect(createDisplay(displayInput)).rejects.toThrow(contactCheckError);
expect(prisma.display.create).not.toHaveBeenCalled();
expect(displayCache.revalidate).not.toHaveBeenCalled();
});
});

View File

@@ -1,85 +0,0 @@
import { cache } from "@/lib/cache";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { getContact } from "./contact";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/cache");
const contactId = "test-contact-id";
const mockContact = {
id: contactId,
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
};
const expectedContactAttributes: TContactAttributes = {
email: "test@example.com",
name: "Test User",
};
describe("getContact", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should return contact with formatted attributes when found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
const result = await getContact(contactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId },
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(result).toEqual({
id: contactId,
attributes: expectedContactAttributes,
});
// Check if cache wrapper was called (though mocked to pass through)
expect(cache).toHaveBeenCalled();
});
test("should return null when contact is not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const result = await getContact(contactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: contactId },
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(result).toBeNull();
// Check if cache wrapper was called (though mocked to pass through)
expect(cache).toHaveBeenCalled();
});
});

View File

@@ -1,224 +0,0 @@
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { responseCache } from "@/lib/response/cache";
import { calculateTtcTotal } from "@/lib/response/utils";
import { responseNoteCache } from "@/lib/responseNote/cache";
import { captureTelemetry } from "@/lib/telemetry";
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 { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
import { getContact } from "./contact";
import { createResponse } from "./response";
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
IS_PRODUCTION: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
SAML_DATABASE_URL: "mock-saml-database-url",
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/cache");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/responseNote/cache");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
create: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger");
vi.mock("./contact");
const environmentId = "test-environment-id";
const surveyId = "test-survey-id";
const organizationId = "test-organization-id";
const responseId = "test-response-id";
const contactId = "test-contact-id";
const userId = "test-user-id";
const displayId = "test-display-id";
const mockOrganization = {
id: organizationId,
name: "Test Org",
billing: {
limits: { monthly: { responses: 100 } },
plan: "free",
},
};
const mockContact: { id: string; attributes: TContactAttributes } = {
id: contactId,
attributes: { userId: userId, email: "test@example.com" },
};
const mockResponseInput: TResponseInputV2 = {
environmentId,
surveyId,
contactId: null,
displayId: null,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
singleUseId: null,
language: "en",
variables: {},
createdAt: new Date(),
updatedAt: new Date(),
};
const mockResponsePrisma = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: "en",
displayId: null,
tags: [],
notes: [],
};
const expectedResponse: TResponse = {
...mockResponsePrisma,
contact: null,
tags: [],
};
describe("createResponse V2", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(validateInputs).mockImplementation(() => {});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(getContact).mockResolvedValue(mockContact);
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ({
...ttc,
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
}));
vi.mocked(responseCache.revalidate).mockResolvedValue(undefined);
vi.mocked(responseNoteCache.revalidate).mockResolvedValue(undefined);
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
});
afterEach(() => {
mockIsFormbricksCloud = false;
});
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
mockIsFormbricksCloud = true;
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
});
test("should throw original error on other errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput); // Should not throw
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
test("should correctly map prisma tags to response tags", async () => {
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
const prismaResponseWithTags = {
...mockResponsePrisma,
tags: [{ tag: mockTag }],
};
vi.mocked(prisma.response.create).mockResolvedValue(prismaResponseWithTags as any);
const result = await createResponse(mockResponseInput);
expect(result.tags).toEqual([mockTag]);
});
});

View File

@@ -9,12 +9,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn().mockImplementation((value, language) => {
return typeof value === "string" ? value : value[language] || value["default"] || "";
}),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({
verifyRecaptchaToken: vi.fn(),
}));

View File

@@ -4,7 +4,6 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
@@ -81,23 +80,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
if (surveyCheckResult) return surveyCheckResult;
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: survey.questions,
responseLanguage: responseInputData.language,
});
if (otherResponseInvalidQuestionId) {
return responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
);
}
let response: TResponse;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -0,0 +1,11 @@
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import formbricks from "@formbricks/js";
export const formbricksLogout = async () => {
const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS);
localStorage.clear();
if (loggedInWith) {
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, loggedInWith);
}
return await formbricks.logout();
};

View File

@@ -3,12 +3,9 @@ import { TUserLocale } from "@formbricks/types/user";
import { env } from "./env";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const IS_PRODUCTION = env.NODE_ENV === "production";
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
export const FORMBRICKS_API_HOST = env.FORMBRICKS_API_HOST;
export const FORMBRICKS_ENVIRONMENT_ID = env.FORMBRICKS_ENVIRONMENT_ID;
export const IS_FORMBRICKS_ENABLED = !!(env.FORMBRICKS_API_HOST && env.FORMBRICKS_ENVIRONMENT_ID);
// URLs
export const WEBAPP_URL =
@@ -33,11 +30,13 @@ export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
export const SAML_OAUTH_ENABLED = !!env.SAML_DATABASE_URL;
export const GOOGLE_OAUTH_ENABLED = env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? true : false;
export const GITHUB_OAUTH_ENABLED = env.GITHUB_ID && env.GITHUB_SECRET ? true : false;
export const AZURE_OAUTH_ENABLED =
env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET && env.AZUREAD_TENANT_ID ? true : false;
export const OIDC_OAUTH_ENABLED =
env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER ? true : false;
export const SAML_OAUTH_ENABLED = env.SAML_DATABASE_URL ? true : false;
export const SAML_XML_DIR = "./saml-connection";
export const GITHUB_ID = env.GITHUB_ID;
@@ -61,7 +60,7 @@ export const SAML_PRODUCT = "formbricks";
export const SAML_AUDIENCE = "https://saml.formbricks.com";
export const SAML_PATH = "/api/auth/saml/callback";
export const SIGNUP_ENABLED = IS_FORMBRICKS_CLOUD || IS_DEVELOPMENT || E2E_TESTING;
export const SIGNUP_ENABLED = env.SIGNUP_DISABLED !== "1";
export const EMAIL_AUTH_ENABLED = env.EMAIL_AUTH_DISABLED !== "1";
export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
@@ -97,11 +96,9 @@ export const RESPONSES_PER_PAGE = 25;
export const TEXT_RESPONSES_PER_PAGE = 5;
export const INSIGHTS_PER_PAGE = 10;
export const DOCUMENTS_PER_PAGE = 10;
export const MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500;
export const MAX_OTHER_OPTION_LENGTH = 250;
export const SKIP_INVITE_FOR_SSO = env.AUTH_SKIP_INVITE_FOR_SSO === "1";
export const DEFAULT_TEAM_ID = env.AUTH_DEFAULT_TEAM_ID;
export const DEFAULT_ORGANIZATION_ID = env.DEFAULT_ORGANIZATION_ID;
export const DEFAULT_ORGANIZATION_ROLE = env.DEFAULT_ORGANIZATION_ROLE;
export const SLACK_MESSAGE_LIMIT = 2995;
export const GOOGLE_SHEET_MESSAGE_LIMIT = 49995;
@@ -115,7 +112,7 @@ export const S3_REGION = env.S3_REGION;
export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL;
export const S3_BUCKET_NAME = env.S3_BUCKET_NAME;
export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1";
export const UPLOADS_DIR = env.UPLOADS_DIR ?? "./uploads";
export const UPLOADS_DIR = env.UPLOADS_DIR || "./uploads";
export const MAX_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
@@ -199,6 +196,7 @@ export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
};
export const DEBUG = env.DEBUG === "1";
export const E2E_TESTING = env.E2E_TESTING === "1";
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
@@ -218,7 +216,7 @@ export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
export const STRIPE_API_VERSION = "2024-06-20";
// Maximum number of attribute classes allowed:
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150 as const;
export const DEFAULT_LOCALE = "en-US";
export const AVAILABLE_LOCALES: TUserLocale[] = ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"];
@@ -279,6 +277,10 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
export const IS_PRODUCTION = env.NODE_ENV === "production";
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";

View File

@@ -17,13 +17,19 @@ export const env = createEnv({
DATABASE_URL: z.string().url(),
DEBUG: z.enum(["1", "0"]).optional(),
DOCKER_CRON_ENABLED: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
DEFAULT_ORGANIZATION_ID: z.string().optional(),
DEFAULT_ORGANIZATION_ROLE: z.enum(["owner", "manager", "member", "billing"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
FORMBRICKS_API_HOST: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
FORMBRICKS_ENVIRONMENT_ID: z.string().optional(),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -75,6 +81,7 @@ export const env = createEnv({
S3_FORCE_PATH_STYLE: z.enum(["1", "0"]).optional(),
SAML_DATABASE_URL: z.string().optional(),
SENTRY_DSN: z.string().optional(),
SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SMTP_HOST: z.string().min(1).optional(),
@@ -124,14 +131,16 @@ export const env = createEnv({
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DEBUG: process.env.DEBUG,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
DEFAULT_ORGANIZATION_ID: process.env.DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE: process.env.DEFAULT_ORGANIZATION_ROLE,
DOCKER_CRON_ENABLED: process.env.DOCKER_CRON_ENABLED,
E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
FORMBRICKS_API_HOST: process.env.FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID: process.env.FORMBRICKS_ENVIRONMENT_ID,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
@@ -175,6 +184,7 @@ export const env = createEnv({
S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL,
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
SAML_DATABASE_URL: process.env.SAML_DATABASE_URL,
SIGNUP_DISABLED: process.env.SIGNUP_DISABLED,
SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID,
SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET,
SMTP_HOST: process.env.SMTP_HOST,

View File

@@ -63,35 +63,18 @@ export const createMembership = async (
},
});
if (existingMembership && existingMembership.role === data.role) {
if (existingMembership) {
return existingMembership;
}
let membership: TMembership;
if (!existingMembership) {
membership = await prisma.membership.create({
data: {
userId,
organizationId,
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
} else {
membership = await prisma.membership.update({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
data: {
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
}
const membership = await prisma.membership.create({
data: {
userId,
organizationId,
accepted: data.accepted,
role: data.role as TMembership["role"],
},
});
organizationCache.revalidate({
userId,
});

View File

@@ -428,7 +428,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
});
segmentCache.revalidate({ id: updatedSegment.id, environmentId: updatedSegment.environmentId });
updatedSegment.surveys.forEach((survey) => surveyCache.revalidate({ id: survey.id }));
updatedSegment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
} catch (error) {
logger.error(error, "Error updating survey");
throw new Error("Error updating survey");
@@ -865,7 +865,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
});
segmentCache.revalidate({ id: currentSurveySegment.id });
segment.surveys.forEach((survey) => surveyCache.revalidate({ id: survey.id }));
segment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
surveyCache.revalidate({ environmentId: segment.environmentId });
}

View File

@@ -1,5 +1,4 @@
export const isValidEmail = (email: string): boolean => {
// This regex comes from zod
const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i;
export const isValidEmail = (email): boolean => {
const regex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
return regex.test(email);
};

View File

@@ -26,6 +26,7 @@ describe("DeleteAccountModal", () => {
const mockOrgs: TOrganization[] = [{ name: "Org1" }, { name: "Org2" }] as TOrganization[];
const mockSetOpen = vi.fn();
const mockLogout = vi.fn();
afterEach(() => {
cleanup();
@@ -39,6 +40,7 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={mockOrgs}
formbricksLogout={mockLogout}
/>
);
@@ -54,6 +56,7 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
@@ -75,6 +78,7 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
@@ -86,6 +90,7 @@ describe("DeleteAccountModal", () => {
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
@@ -109,6 +114,7 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={true}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
@@ -120,6 +126,7 @@ describe("DeleteAccountModal", () => {
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ redirect: true });
expect(window.location.replace).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
@@ -136,6 +143,7 @@ describe("DeleteAccountModal", () => {
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);

View File

@@ -16,6 +16,7 @@ interface DeleteAccountModalProps {
user: TUser;
isFormbricksCloud: boolean;
organizationsWithSingleOwner: TOrganization[];
formbricksLogout: () => Promise<void>;
}
export const DeleteAccountModal = ({
@@ -23,6 +24,7 @@ export const DeleteAccountModal = ({
open,
user,
isFormbricksCloud,
formbricksLogout,
organizationsWithSingleOwner,
}: DeleteAccountModalProps) => {
const { t } = useTranslate();
@@ -36,6 +38,7 @@ export const DeleteAccountModal = ({
try {
setDeleting(true);
await deleteUserAction();
await formbricksLogout();
// redirect to account deletion survey in Formbricks Cloud
if (isFormbricksCloud) {
await signOut({ redirect: true });

View File

@@ -1,77 +0,0 @@
import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { TResponseData } from "@formbricks/types/responses";
import {
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
/**
* Helper function to check if a string value is a valid "other" option
* @returns BadRequestResponse if the value exceeds the limit, undefined otherwise
*/
export const validateOtherOptionLength = (
value: string,
choices: TSurveyQuestionChoice[],
questionId: string,
language?: string
): string | undefined => {
// Check if this is an "other" option (not in predefined choices)
const matchingChoice = choices.find(
(choice) => getLocalizedValue(choice.label, language ?? "default") === value
);
// If this is an "other" option with value that's too long, reject the response
if (!matchingChoice && value.length > MAX_OTHER_OPTION_LENGTH) {
return questionId;
}
};
export const validateOtherOptionLengthForMultipleChoice = ({
responseData,
surveyQuestions,
responseLanguage,
}: {
responseData: TResponseData;
surveyQuestions: TSurveyQuestion[];
responseLanguage?: string;
}): string | undefined => {
for (const [questionId, answer] of Object.entries(responseData)) {
const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) continue;
const isMultiChoice =
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle;
if (!isMultiChoice) continue;
const error = validateAnswer(answer, question.choices, questionId, responseLanguage);
if (error) return error;
}
return undefined;
};
function validateAnswer(
answer: unknown,
choices: TSurveyQuestionChoice[],
questionId: string,
language?: string
): string | undefined {
if (typeof answer === "string") {
return validateOtherOptionLength(answer, choices, questionId, language);
}
if (Array.isArray(answer)) {
for (const item of answer) {
if (typeof item === "string") {
const result = validateOtherOptionLength(item, choices, questionId, language);
if (result) return result;
}
}
}
return undefined;
}

View File

@@ -1,150 +0,0 @@
import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants";
import { describe, expect, test, vi } from "vitest";
import {
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { validateOtherOptionLength, validateOtherOptionLengthForMultipleChoice } from "../question";
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn().mockImplementation((value, language) => {
return typeof value === "string" ? value : value[language] || value["default"] || "";
}),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({
verifyRecaptchaToken: vi.fn(),
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })),
notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })),
},
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSpamProtectionEnabled: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () => ({
getOrganizationBillingByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const mockChoices: TSurveyQuestionChoice[] = [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
];
const surveyQuestions = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: mockChoices,
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
choices: mockChoices,
},
] as unknown as TSurveyQuestion[];
describe("validateOtherOptionLength", () => {
const mockChoices: TSurveyQuestionChoice[] = [
{ id: "1", label: { default: "Option 1", fr: "Option one" } },
{ id: "2", label: { default: "Option 2", fr: "Option two" } },
{ id: "3", label: { default: "Option 3", fr: "Option Trois" } },
];
test("returns undefined when value matches a choice", () => {
const result = validateOtherOptionLength("Option 1", mockChoices, "q1");
expect(result).toBeUndefined();
});
test("returns undefined when other option is within length limit", () => {
const shortValue = "A".repeat(MAX_OTHER_OPTION_LENGTH);
const result = validateOtherOptionLength(shortValue, mockChoices, "q1");
expect(result).toBeUndefined();
});
test("uses default language when no language is provided", () => {
const result = validateOtherOptionLength("Option 3", mockChoices, "q1");
expect(result).toBeUndefined();
});
test("handles localized choice labels", () => {
const result = validateOtherOptionLength("Option Trois", mockChoices, "q1", "fr");
expect(result).toBeUndefined();
});
test("returns bad request response when other option exceeds length limit", () => {
const longValue = "A".repeat(MAX_OTHER_OPTION_LENGTH + 1);
const result = validateOtherOptionLength(longValue, mockChoices, "q1");
expect(result).toBeTruthy();
});
});
describe("validateOtherOptionLengthForMultipleChoice", () => {
test("returns undefined for single choice that matches a valid option", () => {
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q1: "Option 1" },
surveyQuestions,
});
expect(result).toBeUndefined();
});
test("returns undefined for multi-select with all valid options", () => {
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q2: ["Option 1", "Option 2"] },
surveyQuestions,
});
expect(result).toBeUndefined();
});
test("returns questionId for single choice with long 'other' option", () => {
const longText = "X".repeat(MAX_OTHER_OPTION_LENGTH + 1);
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q1: longText },
surveyQuestions,
});
expect(result).toBe("q1");
});
test("returns questionId for multi-select with one long 'other' option", () => {
const longText = "Y".repeat(MAX_OTHER_OPTION_LENGTH + 1);
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q2: [longText] },
surveyQuestions,
});
expect(result).toBe("q2");
});
test("ignores non-matching or unrelated question IDs", () => {
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { unrelated: "Other: something" },
surveyQuestions,
});
expect(result).toBeUndefined();
});
test("returns undefined if answer is not string or array", () => {
const result = validateOtherOptionLengthForMultipleChoice({
responseData: { q1: 123 as any },
surveyQuestions,
});
expect(result).toBeUndefined();
});
});

View File

@@ -1,6 +1,5 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
@@ -137,28 +136,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
});
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: body.data,
surveyQuestions: questionsResponse.data.questions,
responseLanguage: body.language ?? undefined,
});
if (otherResponseInvalidQuestionId) {
return handleApiError(request, {
type: "bad_request",
details: [
{
field: "response",
issue: `Response for question ${otherResponseInvalidQuestionId} exceeds character limit`,
meta: {
questionId: otherResponseInvalidQuestionId,
},
},
],
});
}
const response = await updateResponse(params.responseId, body);
if (!response.ok) {

View File

@@ -1,6 +1,5 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
@@ -91,28 +90,6 @@ export const POST = async (request: Request) =>
});
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: body.data,
surveyQuestions: surveyQuestions.data.questions,
responseLanguage: body.language ?? undefined,
});
if (otherResponseInvalidQuestionId) {
return handleApiError(request, {
type: "bad_request",
details: [
{
field: "response",
issue: `Response for question ${otherResponseInvalidQuestionId} exceeds character limit`,
meta: {
questionId: otherResponseInvalidQuestionId,
},
},
],
});
}
const createResponseResult = await createResponse(environmentId, body);
if (!createResponseResult.ok) {
return handleApiError(request, createResponseResult.error);

View File

@@ -177,17 +177,6 @@ export const authOptions: NextAuthOptions = {
// Conditionally add enterprise SSO providers
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
cookies: {
sessionToken: {
name: "next-auth.session-token",
options: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
},
},
session: {
maxAge: 3600,
},

View File

@@ -4,7 +4,7 @@ import { hashPassword } from "@/lib/auth";
import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@/lib/constants";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { actionClient } from "@/lib/utils/action-client";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
@@ -14,6 +14,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
import { z } from "zod";
import { UnknownError } from "@formbricks/types/errors";
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
import { ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
const ZCreateUserAction = z.object({
@@ -22,6 +23,8 @@ const ZCreateUserAction = z.object({
password: ZUserPassword,
inviteToken: z.string().optional(),
userLocale: ZUserLocale.optional(),
defaultOrganizationId: z.string().optional(),
defaultOrganizationRole: ZOrganizationRole.optional(),
emailVerificationDisabled: z.boolean().optional(),
turnstileToken: z
.string()
@@ -89,21 +92,42 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
await deleteInvite(invite.id);
} else {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (isMultiOrgEnabled) {
const organization = await createOrganization({ name: `${user.name}'s Organization` });
await createMembership(organization.id, user.id, {
role: "owner",
accepted: true,
});
}
// Handle organization assignment
else {
let organizationId: string | undefined;
let role: TOrganizationRole = "owner";
if (parsedInput.defaultOrganizationId) {
// Use existing or create organization with specific ID
let organization = await getOrganization(parsedInput.defaultOrganizationId);
if (!organization) {
organization = await createOrganization({
id: parsedInput.defaultOrganizationId,
name: `${user.name}'s Organization`,
});
} else {
role = parsedInput.defaultOrganizationRole || "owner";
}
organizationId = organization.id;
} else {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (isMultiOrgEnabled) {
// Create new organization
const organization = await createOrganization({ name: `${user.name}'s Organization` });
organizationId = organization.id;
}
}
if (organizationId) {
await createMembership(organizationId, user.id, { role, accepted: true });
await updateUser(user.id, {
notificationSettings: {
...user.notificationSettings,
alert: { ...user.notificationSettings?.alert },
weeklySummary: { ...user.notificationSettings?.weeklySummary },
unsubscribedOrganizationIds: Array.from(
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organizationId])
),
},
});

View File

@@ -119,6 +119,8 @@ const defaultProps = {
isTurnstileConfigured: false,
samlTenant: "",
samlProduct: "",
defaultOrganizationId: "org1",
defaultOrganizationRole: "member",
turnstileSiteKey: "dummy", // not used since isTurnstileConfigured is false
} as const;
@@ -177,6 +179,8 @@ describe("SignupForm", () => {
userLocale: defaultProps.userLocale,
inviteToken: "",
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
defaultOrganizationId: defaultProps.defaultOrganizationId,
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
turnstileToken: undefined,
});
});
@@ -229,6 +233,8 @@ describe("SignupForm", () => {
userLocale: props.userLocale,
inviteToken: "",
emailVerificationDisabled: true,
defaultOrganizationId: props.defaultOrganizationId,
defaultOrganizationRole: props.defaultOrganizationRole,
turnstileToken: "test-turnstile-token",
});
});
@@ -280,6 +286,8 @@ describe("SignupForm", () => {
userLocale: props.userLocale,
inviteToken: "",
emailVerificationDisabled: true,
defaultOrganizationId: props.defaultOrganizationId,
defaultOrganizationRole: props.defaultOrganizationRole,
turnstileToken: "test-turnstile-token",
});
});
@@ -354,6 +362,8 @@ describe("SignupForm", () => {
userLocale: defaultProps.userLocale,
inviteToken: "token123",
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
defaultOrganizationId: defaultProps.defaultOrganizationId,
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
turnstileToken: undefined,
});
});

View File

@@ -19,6 +19,7 @@ import { FormProvider, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import Turnstile, { useTurnstile } from "react-turnstile";
import { z } from "zod";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { createEmailTokenAction } from "../../../auth/actions";
import { PasswordChecks } from "./password-checks";
@@ -44,6 +45,8 @@ interface SignupFormProps {
userLocale: TUserLocale;
emailFromSearchParams?: string;
emailVerificationDisabled: boolean;
defaultOrganizationId?: string;
defaultOrganizationRole?: TOrganizationRole;
isSsoEnabled: boolean;
samlSsoEnabled: boolean;
isTurnstileConfigured: boolean;
@@ -65,6 +68,8 @@ export const SignupForm = ({
userLocale,
emailFromSearchParams,
emailVerificationDisabled,
defaultOrganizationId,
defaultOrganizationRole,
isSsoEnabled,
samlSsoEnabled,
isTurnstileConfigured,
@@ -111,6 +116,8 @@ export const SignupForm = ({
userLocale,
inviteToken: inviteToken || "",
emailVerificationDisabled,
defaultOrganizationId,
defaultOrganizationRole,
turnstileToken,
});

View File

@@ -1,101 +0,0 @@
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { OrganizationRole, Team, TeamUserRole } from "@prisma/client";
/**
* Common constants and IDs used across tests
*/
export const MOCK_DATE = new Date("2023-01-01T00:00:00.000Z");
export const MOCK_IDS = {
// User IDs
userId: "test-user-id",
// Team IDs
teamId: "test-team-id",
defaultTeamId: "team-123",
// Organization IDs
organizationId: "test-org-id",
defaultOrganizationId: "org-123",
// Project IDs
projectId: "test-project-id",
};
/**
* Mock team data structures
*/
export const MOCK_TEAM: {
id: string;
organizationId: string;
projectTeams: { projectId: string }[];
} = {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
projectTeams: [
{
projectId: MOCK_IDS.projectId,
},
],
};
export const MOCK_DEFAULT_TEAM: Team = {
id: MOCK_IDS.defaultTeamId,
organizationId: MOCK_IDS.defaultOrganizationId,
name: "Default Team",
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
/**
* Mock membership data
*/
export const MOCK_TEAM_USER = {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin" as TeamUserRole,
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
export const MOCK_DEFAULT_TEAM_USER = {
teamId: MOCK_IDS.defaultTeamId,
userId: MOCK_IDS.userId,
role: "admin" as TeamUserRole,
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
/**
* Mock invitation data
*/
export const MOCK_INVITE: CreateMembershipInvite = {
organizationId: MOCK_IDS.organizationId,
role: "owner" as OrganizationRole,
teamIds: [MOCK_IDS.teamId],
};
export const MOCK_ORGANIZATION_MEMBERSHIP = {
userId: MOCK_IDS.userId,
role: "owner" as OrganizationRole,
organizationId: MOCK_IDS.defaultOrganizationId,
accepted: true,
};
/**
* Factory functions for creating test data with custom overrides
*/
export const createMockTeam = (overrides = {}) => ({
...MOCK_TEAM,
...overrides,
});
export const createMockTeamUser = (overrides = {}) => ({
...MOCK_TEAM_USER,
...overrides,
});
export const createMockInvite = (overrides = {}) => ({
...MOCK_INVITE,
...overrides,
});

View File

@@ -1,153 +0,0 @@
import { MOCK_IDS, MOCK_INVITE, MOCK_TEAM, MOCK_TEAM_USER } from "./__mocks__/team-mocks";
import { teamCache } from "@/lib/cache/team";
import { projectCache } from "@/lib/project/cache";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { OrganizationRole } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { createTeamMembership } from "../team";
// Setup all mocks
const setupMocks = () => {
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findUnique: vi.fn(),
},
teamUser: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/constants", () => ({
DEFAULT_TEAM_ID: "team-123",
DEFAULT_ORGANIZATION_ID: "org-123",
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn().mockReturnValue("tag-id"),
byOrganizationId: vi.fn().mockReturnValue("tag-org-id"),
},
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock reactCache to control the getDefaultTeam function
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn().mockImplementation((fn) => fn),
};
});
};
// Set up mocks
setupMocks();
describe("Team Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createTeamMembership", () => {
describe("when user is an admin", () => {
test("creates a team membership with admin role", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER);
await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin",
},
});
expect(projectCache.revalidate).toHaveBeenCalledWith({ id: MOCK_IDS.projectId });
expect(teamCache.revalidate).toHaveBeenCalledWith({ id: MOCK_IDS.teamId });
expect(teamCache.revalidate).toHaveBeenCalledWith({
userId: MOCK_IDS.userId,
organizationId: MOCK_IDS.organizationId,
});
expect(projectCache.revalidate).toHaveBeenCalledWith({
userId: MOCK_IDS.userId,
organizationId: MOCK_IDS.organizationId,
});
});
});
describe("when user is not an admin", () => {
test("creates a team membership with contributor role", async () => {
const nonAdminInvite: CreateMembershipInvite = {
...MOCK_INVITE,
role: "member" as OrganizationRole,
};
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.teamUser.create).mockResolvedValue({
...MOCK_TEAM_USER,
role: "contributor",
});
await createTeamMembership(nonAdminInvite, MOCK_IDS.userId);
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "contributor",
},
});
});
});
describe("error handling", () => {
test("throws error when database operation fails", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error");
});
});
});
});

View File

@@ -1,18 +1,14 @@
import "server-only";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team";
import { getAccessFlags } from "@/lib/membership/utils";
import { projectCache } from "@/lib/project/cache";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise<void> => {
const teamIds = invite.teamIds || [];
const userMembershipRole = invite.role;
const { isOwner, isManager } = getAccessFlags(userMembershipRole);
@@ -22,7 +18,18 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
const isOwnerOrManager = isOwner || isManager;
try {
for (const teamId of teamIds) {
const team = await getTeamProjectIds(teamId, invite.organizationId);
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
if (team) {
await prisma.teamUser.create({
@@ -39,7 +46,7 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
}
for (const projectId of validProjectIds) {
projectCache.revalidate({ id: projectId });
teamCache.revalidate({ id: projectId });
}
for (const teamId of validTeamIds) {
@@ -49,7 +56,6 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
teamCache.revalidate({ userId, organizationId: invite.organizationId });
projectCache.revalidate({ userId, organizationId: invite.organizationId });
} catch (error) {
logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -57,34 +63,3 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI
throw error;
}
};
export const getTeamProjectIds = reactCache(
async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> =>
cache(
async () => {
const team = await prisma.team.findUnique({
where: {
id: teamId,
organizationId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
}
return team;
},
[`getTeamProjectIds-${teamId}-${organizationId}`],
{
tags: [teamCache.tag.byId(teamId), teamCache.tag.byOrganizationId(organizationId)],
}
)()
);

View File

@@ -1,5 +1,7 @@
import {
AZURE_OAUTH_ENABLED,
DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE,
EMAIL_AUTH_ENABLED,
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
@@ -75,6 +77,8 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
oidcDisplayName={OIDC_DISPLAY_NAME}
userLocale={locale}
emailFromSearchParams={emailFromSearchParams}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSsoEnabled={isSsoEnabled}
samlSsoEnabled={samlSsoEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}

View File

@@ -196,12 +196,8 @@ export const UploadContactsCSVButton = ({
}
if (result?.validationErrors) {
const csvDataErrors = Array.isArray(result.validationErrors.csvData)
? result.validationErrors.csvData[0]?._errors?.[0]
: result.validationErrors.csvData?._errors?.[0];
if (csvDataErrors) {
setErrror(csvDataErrors);
if (result.validationErrors.csvData?._errors?.[0]) {
setErrror(result.validationErrors.csvData._errors?.[0]);
} else {
setErrror("An error occurred while uploading the contacts. Please try again later.");
}
@@ -297,7 +293,7 @@ export const UploadContactsCSVButton = ({
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
)}
onClick={() => {
resetState(true);
@@ -343,7 +339,7 @@ export const UploadContactsCSVButton = ({
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>

View File

@@ -1,71 +0,0 @@
import { Organization, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getFirstOrganization } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/cache", () => ({
cache: (fn: any) => fn,
}));
vi.mock("react", () => ({
cache: (fn: any) => fn,
}));
describe("getFirstOrganization", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the first organization when found", async () => {
const org: Organization = {
id: "org-1",
name: "Test Org",
createdAt: new Date(),
whitelabel: true,
updatedAt: new Date(),
billing: {
plan: "free",
period: "monthly",
periodStart: new Date(),
stripeCustomerId: "cus_123",
limits: {
monthly: {
miu: 100,
responses: 1000,
},
projects: 3,
},
},
isAIEnabled: false,
};
vi.mocked(prisma.organization.findFirst).mockResolvedValue(org);
const result = await getFirstOrganization();
expect(result).toEqual(org);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({});
});
test("returns null if no organization is found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
const result = await getFirstOrganization();
expect(result).toBeNull();
});
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const error = new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" });
vi.mocked(prisma.organization.findFirst).mockRejectedValue(error);
await expect(getFirstOrganization()).rejects.toThrow(DatabaseError);
});
test("throws unknown error if not PrismaClientKnownRequestError", async () => {
const error = new Error("unexpected");
vi.mocked(prisma.organization.findFirst).mockRejectedValue(error);
await expect(getFirstOrganization()).rejects.toThrow("unexpected");
});
});

View File

@@ -1,27 +0,0 @@
import { cache } from "@/lib/cache";
import { Organization, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
export const getFirstOrganization = reactCache(
async (): Promise<Organization | null> =>
cache(
async () => {
try {
const organization = await prisma.organization.findFirst({});
return organization;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getFirstOrganization`],
{
tags: [],
}
)()
);

View File

@@ -1,8 +1,8 @@
import { createAccount } from "@/lib/account/service";
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { DEFAULT_ORGANIZATION_ID, DEFAULT_ORGANIZATION_ROLE } from "@/lib/constants";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
@@ -12,12 +12,9 @@ import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getRoleManagementPermission,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import type { IdentityProvider, Organization } from "@prisma/client";
import type { IdentityProvider } from "@prisma/client";
import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -123,14 +120,13 @@ export const handleSsoCallback = async ({
// Get multi-org license status
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const isFirstUser = await getIsFreshInstance();
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
return false;
}
// Reject if no callback URL and no default org in self-hosted environment
if (!callbackUrl && !DEFAULT_ORGANIZATION_ID && !isMultiOrgEnabled) {
return false;
}
// Additional security checks for self-hosted instances without default org
if (!DEFAULT_ORGANIZATION_ID && !isMultiOrgEnabled) {
try {
// Parse and validate the callback URL
const isValidCallbackUrl = new URL(callbackUrl);
@@ -161,23 +157,6 @@ export const handleSsoCallback = async ({
}
}
let organization: Organization | null = null;
if (!isFirstUser && !isMultiOrgEnabled) {
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
} else {
organization = await getFirstOrganization();
}
if (!organization) {
return false;
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement && !callbackUrl) return false;
}
const userProfile = await createUser({
name:
userName ||
@@ -195,20 +174,26 @@ export const handleSsoCallback = async ({
// send new user to brevo
createBrevoCustomer({ id: user.id, email: user.email });
if (isMultiOrgEnabled) return true;
// Default organization assignment if env variable is set
if (organization) {
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
if (DEFAULT_ORGANIZATION_ID && DEFAULT_ORGANIZATION_ID.length > 0) {
// check if organization exists
let organization = await getOrganization(DEFAULT_ORGANIZATION_ID);
let isNewOrganization = false;
if (!organization) {
// create organization with id from env
organization = await createOrganization({
id: DEFAULT_ORGANIZATION_ID,
name: userProfile.name + "'s Organization",
});
isNewOrganization = true;
}
const role = isNewOrganization ? "owner" : DEFAULT_ORGANIZATION_ROLE || "manager";
await createMembership(organization.id, userProfile.id, { role: role, accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
await createDefaultTeamMembership(userProfile.id);
}
const updatedNotificationSettings: TUserNotificationSettings = {
...userProfile.notificationSettings,
alert: {

View File

@@ -1,113 +0,0 @@
import "server-only";
import { cache } from "@/lib/cache";
import { teamCache } from "@/lib/cache/team";
import { DEFAULT_TEAM_ID } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { Organization, Team } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
export const getOrganizationByTeamId = reactCache(
async (teamId: string): Promise<Organization | null> =>
cache(
async () => {
validateInputs([teamId, z.string().cuid2()]);
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
organization: true,
},
});
if (!team) {
return null;
}
return team.organization;
} catch (error) {
logger.error(error, `Error getting organization by team id ${teamId}`);
return null;
}
},
[`getOrganizationByTeamId-${teamId}`],
{
tags: [teamCache.tag.byId(teamId)],
}
)()
);
const getTeam = reactCache(
async (teamId: string): Promise<Team> =>
cache(
async () => {
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
});
if (!team) {
throw new Error("Team not found");
}
return team;
} catch (error) {
logger.error(error, `Team not found ${teamId}`);
throw error;
}
},
[`getTeam-${teamId}`],
{
tags: [teamCache.tag.byId(teamId)],
}
)()
);
export const createDefaultTeamMembership = async (userId: string) => {
try {
const defaultTeamId = DEFAULT_TEAM_ID;
if (!defaultTeamId) {
logger.error("Default team ID not found");
return;
}
const defaultTeam = await getTeam(defaultTeamId);
if (!defaultTeam) {
logger.error("Default team not found");
return;
}
const organizationMembership = await getMembershipByUserIdOrganizationId(
userId,
defaultTeam.organizationId
);
if (!organizationMembership) {
logger.error("Organization membership not found");
return;
}
const membershipRole = organizationMembership.role;
await createTeamMembership(
{
organizationId: defaultTeam.organizationId,
role: membershipRole,
teamIds: [defaultTeamId],
},
userId
);
} catch (error) {
logger.error("Error creating default team membership", error);
}
};

View File

@@ -1,101 +0,0 @@
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { OrganizationRole, Team, TeamUserRole } from "@prisma/client";
/**
* Common constants and IDs used across tests
*/
export const MOCK_DATE = new Date("2023-01-01T00:00:00.000Z");
export const MOCK_IDS = {
// User IDs
userId: "test-user-id",
// Team IDs
teamId: "test-team-id",
defaultTeamId: "team-123",
// Organization IDs
organizationId: "test-org-id",
defaultOrganizationId: "org-123",
// Project IDs
projectId: "test-project-id",
};
/**
* Mock team data structures
*/
export const MOCK_TEAM: {
id: string;
organizationId: string;
projectTeams: { projectId: string }[];
} = {
id: MOCK_IDS.teamId,
organizationId: MOCK_IDS.organizationId,
projectTeams: [
{
projectId: MOCK_IDS.projectId,
},
],
};
export const MOCK_DEFAULT_TEAM: Team = {
id: MOCK_IDS.defaultTeamId,
organizationId: MOCK_IDS.defaultOrganizationId,
name: "Default Team",
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
/**
* Mock membership data
*/
export const MOCK_TEAM_USER = {
teamId: MOCK_IDS.teamId,
userId: MOCK_IDS.userId,
role: "admin" as TeamUserRole,
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
export const MOCK_DEFAULT_TEAM_USER = {
teamId: MOCK_IDS.defaultTeamId,
userId: MOCK_IDS.userId,
role: "admin" as TeamUserRole,
createdAt: MOCK_DATE,
updatedAt: MOCK_DATE,
};
/**
* Mock invitation data
*/
export const MOCK_INVITE: CreateMembershipInvite = {
organizationId: MOCK_IDS.organizationId,
role: "owner" as OrganizationRole,
teamIds: [MOCK_IDS.teamId],
};
export const MOCK_ORGANIZATION_MEMBERSHIP = {
userId: MOCK_IDS.userId,
role: "owner" as OrganizationRole,
organizationId: MOCK_IDS.defaultOrganizationId,
accepted: true,
};
/**
* Factory functions for creating test data with custom overrides
*/
export const createMockTeam = (overrides = {}) => ({
...MOCK_TEAM,
...overrides,
});
export const createMockTeamUser = (overrides = {}) => ({
...MOCK_TEAM_USER,
...overrides,
});
export const createMockInvite = (overrides = {}) => ({
...MOCK_INVITE,
...overrides,
});

View File

@@ -1,16 +1,11 @@
import { createAccount } from "@/lib/account/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import type { TSamlNameFields } from "@/modules/auth/types/auth";
import {
getIsMultiOrgEnabled,
getIsSamlSsoEnabled,
getRoleManagementPermission,
getisSsoEnabled,
} from "@/modules/ee/license-check/lib/utils";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TUser } from "@formbricks/types/user";
@@ -36,31 +31,20 @@ vi.mock("@/modules/auth/lib/user", () => ({
createUser: vi.fn(),
}));
vi.mock("@/modules/auth/signup/lib/invite", () => ({
getIsValidInviteToken: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSamlSsoEnabled: vi.fn(),
getisSsoEnabled: vi.fn(),
getRoleManagementPermission: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getIsMultiOrgEnabled: vi.fn().mockResolvedValue(true),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findFirst: vi.fn(),
count: vi.fn(), // Add count mock for user
},
},
}));
vi.mock("@/modules/ee/sso/lib/team", () => ({
getOrganizationByTeamId: vi.fn(),
createDefaultTeamMembership: vi.fn(),
}));
vi.mock("@/lib/account/service", () => ({
createAccount: vi.fn(),
}));
@@ -78,35 +62,21 @@ vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@formbricks/lib/jwt", () => ({
verifyInviteToken: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock environment variables
vi.mock("@/lib/constants", () => ({
SKIP_INVITE_FOR_SSO: 0,
DEFAULT_TEAM_ID: "team-123",
DEFAULT_ORGANIZATION_ID: "org-123",
DEFAULT_ORGANIZATION_ROLE: "member",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
}));
describe("handleSsoCallback", () => {
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
// Default mock implementations
vi.mocked(getisSsoEnabled).mockResolvedValue(true);
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
// Mock organization-related functions
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
@@ -118,7 +88,6 @@ describe("handleSsoCallback", () => {
organizationId: mockOrganization.id,
});
vi.mocked(updateUser).mockResolvedValue({ ...mockUser, id: "user-123" });
vi.mocked(createDefaultTeamMembership).mockResolvedValue(undefined);
});
describe("Early return conditions", () => {
@@ -291,11 +260,11 @@ describe("handleSsoCallback", () => {
expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email });
});
test("should return true when organization doesn't exist with DEFAULT_TEAM_ID", async () => {
test("should create organization and membership for new user when DEFAULT_ORGANIZATION_ID is set", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganizationByTeamId).mockResolvedValue(null);
vi.mocked(getOrganization).mockResolvedValue(null);
const result = await handleSsoCallback({
user: mockUser,
@@ -304,15 +273,29 @@ describe("handleSsoCallback", () => {
});
expect(result).toBe(true);
expect(getRoleManagementPermission).not.toHaveBeenCalled();
expect(createOrganization).toHaveBeenCalledWith({
id: "org-123",
name: expect.stringContaining("Organization"),
});
expect(createMembership).toHaveBeenCalledWith("org-123", mockCreatedUser().id, {
role: "owner",
accepted: true,
});
expect(createAccount).toHaveBeenCalledWith({
...mockAccount,
userId: mockCreatedUser().id,
});
expect(updateUser).toHaveBeenCalledWith(mockCreatedUser().id, {
notificationSettings: expect.objectContaining({
unsubscribedOrganizationIds: ["org-123"],
}),
});
});
test("should return true when organization exists but role management is not enabled", async () => {
test("should use existing organization if it exists", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization);
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
const result = await handleSsoCallback({
user: mockUser,
@@ -321,15 +304,16 @@ describe("handleSsoCallback", () => {
});
expect(result).toBe(true);
expect(createMembership).not.toHaveBeenCalled();
expect(createOrganization).not.toHaveBeenCalled();
expect(createMembership).toHaveBeenCalledWith(mockOrganization.id, mockCreatedUser().id, {
role: "member",
accepted: true,
});
});
});
describe("OpenID Connect name handling", () => {
test("should use oidcUser.name when available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const openIdUser = mockOpenIdUser({
name: "Direct Name",
given_name: "John",
@@ -348,14 +332,16 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "Direct Name",
email: openIdUser.email,
emailVerified: expect.any(Date),
identityProvider: "openid",
identityProviderAccountId: mockOpenIdAccount.providerAccountId,
locale: "en-US",
})
);
});
test("should use given_name + family_name when name is not available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: "John",
@@ -374,14 +360,16 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "John Doe",
email: openIdUser.email,
emailVerified: expect.any(Date),
identityProvider: "openid",
identityProviderAccountId: mockOpenIdAccount.providerAccountId,
locale: "en-US",
})
);
});
test("should use preferred_username when name and given_name/family_name are not available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: undefined,
@@ -401,14 +389,16 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "preferred.user",
email: openIdUser.email,
emailVerified: expect.any(Date),
identityProvider: "openid",
identityProviderAccountId: mockOpenIdAccount.providerAccountId,
locale: "en-US",
})
);
});
test("should fallback to email username when no OIDC name fields are available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const openIdUser = mockOpenIdUser({
name: undefined,
given_name: undefined,
@@ -429,6 +419,11 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "test user",
email: openIdUser.email,
emailVerified: expect.any(Date),
identityProvider: "openid",
identityProviderAccountId: mockOpenIdAccount.providerAccountId,
locale: "en-US",
})
);
});
@@ -436,9 +431,6 @@ describe("handleSsoCallback", () => {
describe("SAML name handling", () => {
test("should use samlUser.name when available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const samlUser = {
...mockUser,
name: "Direct Name",
@@ -458,14 +450,16 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "Direct Name",
email: samlUser.email,
emailVerified: expect.any(Date),
identityProvider: "saml",
identityProviderAccountId: mockSamlAccount.providerAccountId,
locale: "en-US",
})
);
});
test("should use firstName + lastName when name is not available", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const samlUser = {
...mockUser,
name: "",
@@ -485,31 +479,56 @@ describe("handleSsoCallback", () => {
expect(createUser).toHaveBeenCalledWith(
expect.objectContaining({
name: "John Doe",
email: samlUser.email,
emailVerified: expect.any(Date),
identityProvider: "saml",
identityProviderAccountId: mockSamlAccount.providerAccountId,
locale: "en-US",
})
);
});
});
describe("Auto-provisioning and invite handling", () => {
test("should return false when auto-provisioning is disabled and no callback URL or multi-org", async () => {
vi.resetModules();
describe("Organization handling", () => {
test("should handle invalid DEFAULT_ORGANIZATION_ID gracefully", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(getOrganization).mockResolvedValue(null);
vi.mocked(createOrganization).mockRejectedValue(new Error("Invalid organization ID"));
const result = await handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "",
});
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("Invalid organization ID");
expect(result).toBe(false);
expect(createOrganization).toHaveBeenCalled();
expect(createMembership).not.toHaveBeenCalled();
});
test("should handle membership creation failure gracefully", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
vi.mocked(createMembership).mockRejectedValue(new Error("Failed to create membership"));
await expect(
handleSsoCallback({
user: mockUser,
account: mockAccount,
callbackUrl: "http://localhost:3000",
})
).rejects.toThrow("Failed to create membership");
expect(createMembership).toHaveBeenCalled();
});
});
describe("Error handling", () => {
test("should handle database errors", async () => {
test("should handle prisma errors gracefully", async () => {
vi.mocked(prisma.user.findFirst).mockRejectedValue(new Error("Database error"));
await expect(
@@ -521,10 +540,11 @@ describe("handleSsoCallback", () => {
).rejects.toThrow("Database error");
});
test("should handle locale finding errors", async () => {
test("should handle locale finding errors gracefully", async () => {
vi.mocked(findMatchingLocale).mockRejectedValue(new Error("Locale error"));
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
vi.mocked(getUserByEmail).mockResolvedValue(null);
vi.mocked(createUser).mockResolvedValue(mockCreatedUser());
await expect(
handleSsoCallback({

View File

@@ -1,180 +0,0 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { validateInputs } from "@/lib/utils/validate";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "../team";
import {
MOCK_DEFAULT_TEAM,
MOCK_DEFAULT_TEAM_USER,
MOCK_IDS,
MOCK_ORGANIZATION_MEMBERSHIP,
} from "./__mock__/team.mock";
// Setup all mocks
const setupMocks = () => {
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findUnique: vi.fn(),
},
teamUser: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/constants", () => ({
DEFAULT_TEAM_ID: "team-123",
DEFAULT_ORGANIZATION_ID: "org-123",
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn().mockReturnValue("tag-id"),
byOrganizationId: vi.fn().mockReturnValue("tag-org-id"),
},
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((args) => args),
}));
// Mock reactCache to control the getDefaultTeam function
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn().mockImplementation((fn) => fn),
};
});
};
// Set up mocks
setupMocks();
describe("Team Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createDefaultTeamMembership", () => {
describe("when all dependencies are available", () => {
test("creates the default team membership successfully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(MOCK_ORGANIZATION_MEMBERSHIP);
vi.mocked(prisma.team.findUnique).mockResolvedValue({
projectTeams: { projectId: ["test-project-id"] },
});
vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_DEFAULT_TEAM_USER);
await createDefaultTeamMembership(MOCK_IDS.userId);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: {
id: "team-123",
},
});
expect(prisma.teamUser.create).toHaveBeenCalledWith({
data: {
teamId: "team-123",
userId: MOCK_IDS.userId,
role: "admin",
},
});
});
});
describe("error handling", () => {
test("handles missing default team gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
await createDefaultTeamMembership(MOCK_IDS.userId);
});
test("handles missing organization membership gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
await createDefaultTeamMembership(MOCK_IDS.userId);
});
test("handles database errors gracefully", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(MOCK_ORGANIZATION_MEMBERSHIP);
vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error"));
await createDefaultTeamMembership(MOCK_IDS.userId);
});
});
});
describe("getOrganizationByTeamId", () => {
const mockOrganization = { id: "org-1", name: "Test Org" };
beforeEach(() => {
vi.clearAllMocks();
});
test("returns organization when team is found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({
organization: mockOrganization,
} as any);
const result = await getOrganizationByTeamId("team-1");
expect(result).toEqual(mockOrganization);
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: { id: "team-1" },
select: { organization: true },
});
});
test("returns null when team is not found", async () => {
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null);
const result = await getOrganizationByTeamId("team-2");
expect(result).toBeNull();
});
test("returns null and logs error when prisma throws", async () => {
const error = new Error("DB error");
vi.mocked(prisma.team.findUnique).mockRejectedValueOnce(error);
const result = await getOrganizationByTeamId("team-3");
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(error, "Error getting organization by team id team-3");
});
test("calls validateInputs with correct arguments", async () => {
const mockTeamId = "team-xyz";
vi.mocked(prisma.team.findUnique).mockResolvedValueOnce({ organization: mockOrganization } as any);
await getOrganizationByTeamId(mockTeamId);
expect(validateInputs).toHaveBeenCalledWith([mockTeamId, expect.anything()]);
});
});
});

View File

@@ -365,7 +365,7 @@ export const AddApiKeyModal = ({
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="py-1 text-sm">{t(getOrganizationAccessKeyDisplayName(key))}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}

View File

@@ -178,7 +178,7 @@ export const ViewPermissionModal = ({
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="py-1 text-sm">{t(getOrganizationAccessKeyDisplayName(key))}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}

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