mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 06:52:01 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5610c8128 | |||
| 3b1cddb9ce | |||
| bd22aaaa86 | |||
| e0e42d2eed | |||
| 616210f1bf | |||
| ff2e7f6cc7 | |||
| d1ce037f7d | |||
| 91f87f4b7b | |||
| 61657b9f9a | |||
| 476d032642 | |||
| 7538e570c5 | |||
| 66fcf4b79b | |||
| 21371b1815 | |||
| a53c13d6ed | |||
| 1a0c6e72b2 | |||
| ba7c8b79b1 | |||
| d7b504eed0 | |||
| a1df10eb09 | |||
| 92be409d4f | |||
| 665c7c6bf1 | |||
| 6c2ff7ee08 |
+3
-10
@@ -93,10 +93,6 @@ 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
|
||||
|
||||
@@ -158,10 +154,6 @@ 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=
|
||||
@@ -180,8 +172,9 @@ 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=
|
||||
@@ -224,4 +217,4 @@ UNKEY_ROOT_KEY=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Disable the user management from UI
|
||||
# DISABLE_USER_MANAGEMENT
|
||||
# DISABLE_USER_MANAGEMENT=1
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Build & Cache Web Binaries
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
|
||||
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -46,11 +46,11 @@ jobs:
|
||||
--health-retries=5
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
statuses: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: fail if conditional jobs failed
|
||||
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
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.
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/next.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/// <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.
|
||||
@@ -1,17 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "tailwindui.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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:*"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
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'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 'Reset' 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 'Free'
|
||||
</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>{" "}
|
||||
'Plan' to 'Free'. 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 'Paid'
|
||||
</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>{" "}
|
||||
'Plan' to 'Paid'. 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>{" "}
|
||||
'test@web.com'
|
||||
</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 'one', 'two', 'three'.
|
||||
</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 'de'
|
||||
</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 'de'.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 629 B |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"exclude": ["node_modules"],
|
||||
"extends": "@formbricks/config-typescript/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -11,13 +11,12 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"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",
|
||||
@@ -27,14 +26,13 @@
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.1",
|
||||
"@typescript-eslint/parser": "8.29.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.12",
|
||||
"tsup": "8.4.0",
|
||||
"vite": "6.2.5"
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-10
@@ -79,7 +79,6 @@ RUN npm install -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
&& apk add --no-cache supercronic \
|
||||
# && addgroup --system --gid 1001 nodejs \
|
||||
&& addgroup -S nextjs \
|
||||
&& adduser -S -u 1001 -G nextjs nextjs
|
||||
@@ -129,8 +128,7 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
||||
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
|
||||
@@ -156,12 +154,6 @@ VOLUME /home/nextjs/apps/web/uploads/
|
||||
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) && \
|
||||
CMD (cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
-2
@@ -1,6 +1,5 @@
|
||||
"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";
|
||||
@@ -125,7 +124,6 @@ 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")}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
"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;
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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";
|
||||
@@ -265,7 +264,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
@@ -392,7 +391,6 @@ 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")}
|
||||
|
||||
@@ -26,13 +26,6 @@ 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>,
|
||||
}));
|
||||
|
||||
-2
@@ -1,6 +1,5 @@
|
||||
"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";
|
||||
@@ -37,7 +36,6 @@ export const DeleteAccount = ({
|
||||
setOpen={setModalOpen}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
formbricksLogout={formbricksLogout}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
/>
|
||||
<p className="text-sm text-slate-700">
|
||||
|
||||
+2
-2
@@ -409,7 +409,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
value: label,
|
||||
count,
|
||||
@@ -508,7 +508,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
rating: parseInt(label),
|
||||
count,
|
||||
|
||||
@@ -36,14 +36,10 @@ 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" />,
|
||||
}));
|
||||
@@ -74,17 +70,5 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import {
|
||||
FORMBRICKS_API_HOST,
|
||||
FORMBRICKS_ENVIRONMENT_ID,
|
||||
IS_FORMBRICKS_ENABLED,
|
||||
IS_POSTHOG_CONFIGURED,
|
||||
POSTHOG_API_HOST,
|
||||
POSTHOG_API_KEY,
|
||||
} from "@/lib/constants";
|
||||
import { 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";
|
||||
@@ -38,15 +30,6 @@ 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}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { convertResponseValue } from "@/lib/responses";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TWeeklyEmailResponseData,
|
||||
TWeeklySummaryEnvironmentData,
|
||||
TWeeklySummarySurveyData,
|
||||
} from "@formbricks/types/weekly-summary";
|
||||
import { getNotificationResponse } from "./notificationResponse";
|
||||
|
||||
vi.mock("@/lib/responses", () => ({
|
||||
convertResponseValue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: vi.fn((survey) => survey),
|
||||
}));
|
||||
|
||||
describe("getNotificationResponse", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should return a notification response with calculated insights and survey data when provided with an environment containing multiple surveys", () => {
|
||||
const mockSurveys = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "question1",
|
||||
headline: { default: "Question 1" },
|
||||
type: "text",
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displays: [{ id: "display1" }],
|
||||
responses: [
|
||||
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
|
||||
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
|
||||
],
|
||||
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
|
||||
{
|
||||
id: "survey2",
|
||||
name: "Survey 2",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "question2",
|
||||
headline: { default: "Question 2" },
|
||||
type: "text",
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displays: [{ id: "display2" }],
|
||||
responses: [
|
||||
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
|
||||
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
|
||||
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
|
||||
],
|
||||
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
|
||||
] as unknown as TWeeklySummarySurveyData[];
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "env1",
|
||||
surveys: mockSurveys,
|
||||
} as unknown as TWeeklySummaryEnvironmentData;
|
||||
|
||||
const projectName = "Project Name";
|
||||
|
||||
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
|
||||
|
||||
expect(notificationResponse).toBeDefined();
|
||||
expect(notificationResponse.environmentId).toBe("env1");
|
||||
expect(notificationResponse.projectName).toBe(projectName);
|
||||
expect(notificationResponse.surveys).toHaveLength(2);
|
||||
|
||||
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
|
||||
expect(notificationResponse.insights.totalDisplays).toBe(2);
|
||||
expect(notificationResponse.insights.totalResponses).toBe(5);
|
||||
expect(notificationResponse.insights.completionRate).toBe(60);
|
||||
expect(notificationResponse.insights.numLiveSurvey).toBe(2);
|
||||
|
||||
expect(notificationResponse.surveys[0].id).toBe("survey1");
|
||||
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
|
||||
expect(notificationResponse.surveys[0].status).toBe("inProgress");
|
||||
expect(notificationResponse.surveys[0].responseCount).toBe(2);
|
||||
|
||||
expect(notificationResponse.surveys[1].id).toBe("survey2");
|
||||
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
|
||||
expect(notificationResponse.surveys[1].status).toBe("inProgress");
|
||||
expect(notificationResponse.surveys[1].responseCount).toBe(3);
|
||||
});
|
||||
|
||||
test("should calculate the correct completion rate and other insights when surveys have responses with varying statuses", () => {
|
||||
const mockSurveys = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "question1",
|
||||
headline: { default: "Question 1" },
|
||||
type: "text",
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displays: [{ id: "display1" }],
|
||||
responses: [
|
||||
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
|
||||
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
|
||||
],
|
||||
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
|
||||
{
|
||||
id: "survey2",
|
||||
name: "Survey 2",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "question2",
|
||||
headline: { default: "Question 2" },
|
||||
type: "text",
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displays: [{ id: "display2" }],
|
||||
responses: [
|
||||
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
|
||||
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
|
||||
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
|
||||
],
|
||||
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
|
||||
{
|
||||
id: "survey3",
|
||||
name: "Survey 3",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "question3",
|
||||
headline: { default: "Question 3" },
|
||||
type: "text",
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displays: [{ id: "display3" }],
|
||||
responses: [{ id: "response6", finished: false, data: { question3: "Answer 6" } }],
|
||||
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
|
||||
] as unknown as TWeeklySummarySurveyData[];
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "env1",
|
||||
surveys: mockSurveys,
|
||||
} as unknown as TWeeklySummaryEnvironmentData;
|
||||
|
||||
const projectName = "Project Name";
|
||||
|
||||
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
|
||||
|
||||
expect(notificationResponse).toBeDefined();
|
||||
expect(notificationResponse.environmentId).toBe("env1");
|
||||
expect(notificationResponse.projectName).toBe(projectName);
|
||||
expect(notificationResponse.surveys).toHaveLength(3);
|
||||
|
||||
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
|
||||
expect(notificationResponse.insights.totalDisplays).toBe(3);
|
||||
expect(notificationResponse.insights.totalResponses).toBe(6);
|
||||
expect(notificationResponse.insights.completionRate).toBe(50);
|
||||
expect(notificationResponse.insights.numLiveSurvey).toBe(3);
|
||||
|
||||
expect(notificationResponse.surveys[0].id).toBe("survey1");
|
||||
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
|
||||
expect(notificationResponse.surveys[0].status).toBe("inProgress");
|
||||
expect(notificationResponse.surveys[0].responseCount).toBe(2);
|
||||
|
||||
expect(notificationResponse.surveys[1].id).toBe("survey2");
|
||||
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
|
||||
expect(notificationResponse.surveys[1].status).toBe("inProgress");
|
||||
expect(notificationResponse.surveys[1].responseCount).toBe(3);
|
||||
|
||||
expect(notificationResponse.surveys[2].id).toBe("survey3");
|
||||
expect(notificationResponse.surveys[2].name).toBe("Survey 3");
|
||||
expect(notificationResponse.surveys[2].status).toBe("inProgress");
|
||||
expect(notificationResponse.surveys[2].responseCount).toBe(1);
|
||||
});
|
||||
|
||||
test("should return default insights and an empty surveys array when the environment contains no surveys", () => {
|
||||
const mockEnvironment = {
|
||||
id: "env1",
|
||||
surveys: [],
|
||||
} as unknown as TWeeklySummaryEnvironmentData;
|
||||
|
||||
const projectName = "Project Name";
|
||||
|
||||
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
|
||||
|
||||
expect(notificationResponse).toBeDefined();
|
||||
expect(notificationResponse.environmentId).toBe("env1");
|
||||
expect(notificationResponse.projectName).toBe(projectName);
|
||||
expect(notificationResponse.surveys).toHaveLength(0);
|
||||
|
||||
expect(notificationResponse.insights.totalCompletedResponses).toBe(0);
|
||||
expect(notificationResponse.insights.totalDisplays).toBe(0);
|
||||
expect(notificationResponse.insights.totalResponses).toBe(0);
|
||||
expect(notificationResponse.insights.completionRate).toBe(0);
|
||||
expect(notificationResponse.insights.numLiveSurvey).toBe(0);
|
||||
});
|
||||
|
||||
test("should handle missing response data gracefully when a response doesn't contain data for a question ID", () => {
|
||||
const mockSurveys = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "question1",
|
||||
headline: { default: "Question 1" },
|
||||
type: "text",
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displays: [{ id: "display1" }],
|
||||
responses: [
|
||||
{ id: "response1", finished: true, data: {} }, // Response missing data for question1
|
||||
],
|
||||
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
|
||||
] as unknown as TWeeklySummarySurveyData[];
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "env1",
|
||||
surveys: mockSurveys,
|
||||
} as unknown as TWeeklySummaryEnvironmentData;
|
||||
|
||||
const projectName = "Project Name";
|
||||
|
||||
// Mock the convertResponseValue function to handle the missing data case
|
||||
vi.mocked(convertResponseValue).mockReturnValue("");
|
||||
|
||||
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
|
||||
|
||||
expect(notificationResponse).toBeDefined();
|
||||
expect(notificationResponse.surveys).toHaveLength(1);
|
||||
expect(notificationResponse.surveys[0].responses).toHaveLength(1);
|
||||
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("");
|
||||
});
|
||||
|
||||
test("should handle unsupported question types gracefully", () => {
|
||||
const mockSurveys = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "question1",
|
||||
headline: { default: "Question 1" },
|
||||
type: "unsupported",
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
displays: [{ id: "display1" }],
|
||||
responses: [{ id: "response1", finished: true, data: { question1: "Answer 1" } }],
|
||||
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
|
||||
] as unknown as TWeeklySummarySurveyData[];
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "env1",
|
||||
surveys: mockSurveys,
|
||||
} as unknown as TWeeklySummaryEnvironmentData;
|
||||
|
||||
const projectName = "Project Name";
|
||||
|
||||
vi.mocked(convertResponseValue).mockReturnValue("Unsupported Response");
|
||||
|
||||
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
|
||||
|
||||
expect(notificationResponse).toBeDefined();
|
||||
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("Unsupported Response");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getOrganizationIds } from "./organization";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
organization: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Organization", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("getOrganizationIds should return an array of organization IDs when the database contains multiple organizations", async () => {
|
||||
const mockOrganizations = [{ id: "org1" }, { id: "org2" }, { id: "org3" }];
|
||||
|
||||
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations);
|
||||
|
||||
const organizationIds = await getOrganizationIds();
|
||||
|
||||
expect(organizationIds).toEqual(["org1", "org2", "org3"]);
|
||||
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.organization.findMany).toHaveBeenCalledWith({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("getOrganizationIds should return an empty array when the database contains no organizations", async () => {
|
||||
vi.mocked(prisma.organization.findMany).mockResolvedValue([]);
|
||||
|
||||
const organizationIds = await getOrganizationIds();
|
||||
|
||||
expect(organizationIds).toEqual([]);
|
||||
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.organization.findMany).toHaveBeenCalledWith({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getProjectsByOrganizationId } from "./project";
|
||||
|
||||
const mockProjects = [
|
||||
{
|
||||
id: "project1",
|
||||
name: "Project 1",
|
||||
environments: [
|
||||
{
|
||||
id: "env1",
|
||||
type: "production",
|
||||
surveys: [],
|
||||
attributeKeys: [],
|
||||
},
|
||||
],
|
||||
organization: {
|
||||
memberships: [
|
||||
{
|
||||
user: {
|
||||
id: "user1",
|
||||
email: "test@example.com",
|
||||
notificationSettings: {
|
||||
weeklySummary: {
|
||||
project1: true,
|
||||
},
|
||||
},
|
||||
locale: "en",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // Set to 6 days ago to be within the last 7 days
|
||||
|
||||
const mockProjectsWithNoEnvironments = [
|
||||
{
|
||||
id: "project3",
|
||||
name: "Project 3",
|
||||
environments: [],
|
||||
organization: {
|
||||
memberships: [
|
||||
{
|
||||
user: {
|
||||
id: "user1",
|
||||
email: "test@example.com",
|
||||
notificationSettings: {
|
||||
weeklySummary: {
|
||||
project3: true,
|
||||
},
|
||||
},
|
||||
locale: "en",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Project Management", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("getProjectsByOrganizationId", () => {
|
||||
test("retrieves projects with environments, surveys, and organization memberships for a valid organization ID", async () => {
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
|
||||
|
||||
const organizationId = "testOrgId";
|
||||
const projects = await getProjectsByOrganizationId(organizationId);
|
||||
|
||||
expect(projects).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
where: {
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
surveys: {
|
||||
where: {
|
||||
NOT: {
|
||||
AND: [
|
||||
{ status: "completed" },
|
||||
{
|
||||
responses: {
|
||||
none: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
not: "draft",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
status: true,
|
||||
responses: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
hiddenFields: true,
|
||||
},
|
||||
},
|
||||
attributeKeys: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
key: true,
|
||||
isUnique: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("handles date calculations correctly across DST boundaries", async () => {
|
||||
const mockDate = new Date(2024, 10, 3, 0, 0, 0); // November 3, 2024, 00:00:00 (example DST boundary)
|
||||
const sevenDaysAgo = new Date(mockDate);
|
||||
sevenDaysAgo.setDate(mockDate.getDate() - 7);
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(mockDate);
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
|
||||
|
||||
const organizationId = "testOrgId";
|
||||
await getProjectsByOrganizationId(organizationId);
|
||||
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
organizationId: organizationId,
|
||||
},
|
||||
select: expect.objectContaining({
|
||||
environments: expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
surveys: expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
NOT: expect.objectContaining({
|
||||
AND: expect.arrayContaining([
|
||||
expect.objectContaining({ status: "completed" }),
|
||||
expect.objectContaining({
|
||||
responses: expect.objectContaining({
|
||||
none: expect.objectContaining({
|
||||
createdAt: expect.objectContaining({
|
||||
gte: sevenDaysAgo,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("includes surveys with 'completed' status but responses within the last 7 days", async () => {
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
|
||||
|
||||
const organizationId = "testOrgId";
|
||||
const projects = await getProjectsByOrganizationId(organizationId);
|
||||
|
||||
expect(projects).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
where: {
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
surveys: {
|
||||
where: {
|
||||
NOT: {
|
||||
AND: [
|
||||
{ status: "completed" },
|
||||
{
|
||||
responses: {
|
||||
none: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
not: "draft",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
status: true,
|
||||
responses: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
hiddenFields: true,
|
||||
},
|
||||
},
|
||||
attributeKeys: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
key: true,
|
||||
isUnique: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty array when an invalid organization ID is provided", async () => {
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const invalidOrganizationId = "invalidOrgId";
|
||||
const projects = await getProjectsByOrganizationId(invalidOrganizationId);
|
||||
|
||||
expect(projects).toEqual([]);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: invalidOrganizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
where: {
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
surveys: {
|
||||
where: {
|
||||
NOT: {
|
||||
AND: [
|
||||
{ status: "completed" },
|
||||
{
|
||||
responses: {
|
||||
none: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
not: "draft",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
status: true,
|
||||
responses: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
hiddenFields: true,
|
||||
},
|
||||
},
|
||||
attributeKeys: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
key: true,
|
||||
isUnique: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("handles projects with no environments", async () => {
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjectsWithNoEnvironments);
|
||||
|
||||
const organizationId = "testOrgId";
|
||||
const projects = await getProjectsByOrganizationId(organizationId);
|
||||
|
||||
expect(projects).toEqual(mockProjectsWithNoEnvironments);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
where: {
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
surveys: {
|
||||
where: {
|
||||
NOT: {
|
||||
AND: [
|
||||
{ status: "completed" },
|
||||
{
|
||||
responses: {
|
||||
none: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
not: "draft",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
questions: true,
|
||||
status: true,
|
||||
responses: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
finished: true,
|
||||
data: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
displays: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: expect.any(Date),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
hiddenFields: true,
|
||||
},
|
||||
},
|
||||
attributeKeys: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
key: true,
|
||||
isUnique: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { TContact } from "@/modules/ee/contacts/types/contact";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock cache\
|
||||
vi.mock("@/lib/cache", async () => {
|
||||
const actual = await vi.importActual("@/lib/cache");
|
||||
return {
|
||||
...(actual as any),
|
||||
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
|
||||
};
|
||||
});
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const userId = "test-user-id";
|
||||
const contactId = "test-contact-id";
|
||||
|
||||
const contactMock: Partial<TContact> & {
|
||||
attributes: { value: string; attributeKey: { key: string } }[];
|
||||
} = {
|
||||
id: contactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "userId" }, value: userId },
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return contact if found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
|
||||
|
||||
const contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
expect(contact).toEqual(contactMock);
|
||||
});
|
||||
|
||||
test("should return null if contact not found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
expect(contact).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||
import { diffInDays } from "@/lib/utils/datetime";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
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 { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getSyncSurveys } from "./survey";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache", async () => {
|
||||
const actual = await vi.importActual("@/lib/cache");
|
||||
return {
|
||||
...(actual as any),
|
||||
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
anySurveyHasFilters: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
diffInDays: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
evaluateSegment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
display: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const contactId = "test-contact-id";
|
||||
const contactAttributes = { userId: "user1", email: "test@example.com" };
|
||||
const deviceType = "desktop";
|
||||
|
||||
const mockProject = {
|
||||
id: "proj1",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "org1",
|
||||
environments: [],
|
||||
recontactDays: 10,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
languages: [],
|
||||
} as unknown as TProject;
|
||||
|
||||
const baseSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey 1",
|
||||
environmentId: environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
endings: [],
|
||||
triggers: [],
|
||||
languages: [],
|
||||
variables: [],
|
||||
hiddenFields: { enabled: false },
|
||||
createdBy: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
projectOverwrites: null,
|
||||
runOnDate: null,
|
||||
showLanguageSwitch: false,
|
||||
isBackButtonHidden: false,
|
||||
followUps: [],
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
};
|
||||
|
||||
describe("getSyncSurveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(true);
|
||||
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should throw error if product not found", async () => {
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
"Product not found"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return empty array if no surveys found", async () => {
|
||||
vi.mocked(getSurveys).mockResolvedValue([]);
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return empty array if no 'app' type surveys in progress", async () => {
|
||||
const surveys: TSurvey[] = [
|
||||
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
|
||||
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
|
||||
];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displayOnce'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); // Already displayed
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displayMultiple'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]); // Already responded
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displaySome'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
{ id: "d1", surveyId: "s1", contactId },
|
||||
{ id: "d2", surveyId: "s1", contactId },
|
||||
]); // Display limit reached
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); // Within limit
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
|
||||
// Test with response already submitted
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]);
|
||||
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result3).toEqual([]);
|
||||
});
|
||||
|
||||
test("should not filter by displayOption 'respondMultiple'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]);
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by product recontactDays if survey recontactDays is null", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
const displayDate = new Date();
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
{ id: "d1", surveyId: "s2", contactId, createdAt: displayDate }, // Display for another survey
|
||||
]);
|
||||
|
||||
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
|
||||
|
||||
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should return surveys if no segment filters exist", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual(surveys);
|
||||
expect(evaluateSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should evaluate segment filters if they exist", async () => {
|
||||
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
|
||||
|
||||
// Case 1: Segment evaluation matches
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(true);
|
||||
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result1).toEqual(surveys);
|
||||
expect(evaluateSegment).toHaveBeenCalledWith(
|
||||
{
|
||||
attributes: contactAttributes,
|
||||
deviceType,
|
||||
environmentId,
|
||||
contactId,
|
||||
userId: contactAttributes.userId,
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
|
||||
// Case 2: Segment evaluation does not match
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(false);
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(getSurveys).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError);
|
||||
});
|
||||
|
||||
test("should handle general errors", async () => {
|
||||
const generalError = new Error("Something went wrong");
|
||||
vi.mocked(getSurveys).mockRejectedValue(generalError);
|
||||
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
generalError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
|
||||
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
|
||||
|
||||
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
|
||||
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
|
||||
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
|
||||
// Let's assume the filter logic works correctly and test the intended path.
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]); // Expect empty array, not an error in this case.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEnding,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { replaceAttributeRecall } from "./utils";
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
parseRecallInfo: vi.fn((text, attributes) => {
|
||||
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
|
||||
const match = text.match(recallPattern);
|
||||
if (match && match[1]) {
|
||||
const recallKey = match[1];
|
||||
const attributeValue = attributes[recallKey];
|
||||
if (attributeValue !== undefined) {
|
||||
return text.replace(recallPattern, `parsed-${attributeValue}`);
|
||||
}
|
||||
}
|
||||
return text; // Return original text if no match or attribute not found
|
||||
}),
|
||||
}));
|
||||
|
||||
const baseSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
environmentId: "env1",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
endings: [],
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
hiddenFields: { enabled: false },
|
||||
variables: [],
|
||||
createdBy: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
projectOverwrites: null,
|
||||
runOnDate: null,
|
||||
showLanguageSwitch: false,
|
||||
isBackButtonHidden: false,
|
||||
followUps: [],
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const attributes: TAttributes = {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
plan: "premium",
|
||||
};
|
||||
|
||||
describe("replaceAttributeRecall", () => {
|
||||
test("should replace recall info in question headlines and subheaders", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Hello recall:name!" },
|
||||
subheader: { default: "Your email is recall:email" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
|
||||
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
|
||||
});
|
||||
|
||||
test("should replace recall info in welcome card headline", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome, recall:name!" },
|
||||
html: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
|
||||
});
|
||||
|
||||
test("should replace recall info in end screen headlines and subheaders", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you, recall:name!" },
|
||||
subheader: { default: "Your plan: recall:plan" },
|
||||
buttonLabel: { default: "Finish" },
|
||||
buttonLink: "https://example.com",
|
||||
} as unknown as TSurveyEnding,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.endings[0].type).toBe("endScreen");
|
||||
if (result.endings[0].type === "endScreen") {
|
||||
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
|
||||
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle multiple languages", () => {
|
||||
const surveyMultiLang: TSurvey = {
|
||||
...baseSurvey,
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
|
||||
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
|
||||
],
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next", es: "Siguiente" },
|
||||
placeholder: { default: "Type here...", es: "Escribe aquí..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyMultiLang, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
|
||||
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
|
||||
});
|
||||
|
||||
test("should not replace if recall key is not in attributes", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Your company: recall:company" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
|
||||
});
|
||||
|
||||
test("should handle surveys with no recall information", async () => {
|
||||
const surveyNoRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Just a regular question" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome!" },
|
||||
html: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
endings: [
|
||||
{
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you!" },
|
||||
buttonLabel: { default: "Finish" },
|
||||
} as unknown as TSurveyEnding,
|
||||
],
|
||||
};
|
||||
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
|
||||
|
||||
const result = replaceAttributeRecall(surveyNoRecall, attributes);
|
||||
expect(result).toEqual(surveyNoRecall); // Should be unchanged
|
||||
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
|
||||
parseRecallInfoSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
|
||||
const surveyEmpty: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [],
|
||||
endings: [],
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
};
|
||||
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
|
||||
|
||||
const result = replaceAttributeRecall(surveyEmpty, attributes);
|
||||
expect(result).toEqual(surveyEmpty);
|
||||
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
|
||||
parseRecallInfoSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentStateActionClass } from "@formbricks/types/js";
|
||||
import { getActionClassesForEnvironmentState } from "./actionClass";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
actionClass: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const mockActionClasses: TJsEnvironmentStateActionClass[] = [
|
||||
{
|
||||
id: "action1",
|
||||
type: "code",
|
||||
name: "Code Action",
|
||||
key: "code-action",
|
||||
noCodeConfig: null,
|
||||
},
|
||||
{
|
||||
id: "action2",
|
||||
type: "noCode",
|
||||
name: "No Code Action",
|
||||
key: null,
|
||||
noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
|
||||
},
|
||||
];
|
||||
|
||||
describe("getActionClassesForEnvironmentState", () => {
|
||||
test("should return action classes successfully", async () => {
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
|
||||
const result = await getActionClassesForEnvironmentState(environmentId);
|
||||
|
||||
expect(result).toEqual(mockActionClasses);
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // ZId is an object
|
||||
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
name: true,
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
},
|
||||
});
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getActionClassesForEnvironmentState-${environmentId}`],
|
||||
{ tags: [`environments-${environmentId}-actionClasses`] }
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on prisma error", async () => {
|
||||
const mockError = new Error("Prisma error");
|
||||
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(mockError);
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
|
||||
await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
|
||||
await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(
|
||||
`Database error when fetching actions for environment ${environmentId}`
|
||||
);
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
|
||||
expect(prisma.actionClass.findMany).toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getActionClassesForEnvironmentState-${environmentId}`],
|
||||
{ tags: [`environments-${environmentId}-actionClasses`] }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { projectCache } from "@/lib/project/cache";
|
||||
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 { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentStateProject } from "@formbricks/types/js";
|
||||
import { getProjectForEnvironmentState } from "./project";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/project/cache");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate"); // Mock validateInputs if needed, though it's often tested elsewhere
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const mockProject: TJsEnvironmentStateProject = {
|
||||
id: "test-project-id",
|
||||
recontactDays: 30,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
styling: { allowStyleOverwrite: false },
|
||||
};
|
||||
|
||||
describe("getProjectForEnvironmentState", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Mock cache implementation
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
|
||||
// Mock projectCache tags
|
||||
vi.mocked(projectCache.tag.byEnvironmentId).mockReturnValue(`project-env-${environmentId}`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return project state successfully", async () => {
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
|
||||
|
||||
const result = await getProjectForEnvironmentState(environmentId);
|
||||
|
||||
expect(result).toEqual(mockProject);
|
||||
expect(prisma.project.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environments: {
|
||||
some: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
recontactDays: true,
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: true,
|
||||
placement: true,
|
||||
inAppSurveyBranding: true,
|
||||
styling: true,
|
||||
},
|
||||
});
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getProjectForEnvironmentState-${environmentId}`],
|
||||
{
|
||||
tags: [`project-env-${environmentId}`],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("should return null if project not found", async () => {
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getProjectForEnvironmentState(environmentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.project.findFirst).toHaveBeenCalledTimes(1);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2001",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting project for environment state");
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should re-throw unknown errors", async () => {
|
||||
const unknownError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.project.findFirst).mockRejectedValue(unknownError);
|
||||
|
||||
await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(unknownError);
|
||||
expect(logger.error).not.toHaveBeenCalled(); // Should not log unknown errors here
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
||||
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 { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { getSurveysForEnvironmentState } from "./survey";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@/modules/survey/lib/utils");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
|
||||
const mockPrismaSurvey = {
|
||||
id: "survey-1",
|
||||
welcomeCard: { enabled: false },
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
variables: [],
|
||||
type: "app",
|
||||
showLanguageSwitch: false,
|
||||
languages: [],
|
||||
endings: [],
|
||||
autoClose: null,
|
||||
styling: null,
|
||||
status: "inProgress",
|
||||
recaptcha: null,
|
||||
segment: null,
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
displayOption: "displayOnce",
|
||||
hiddenFields: { enabled: false },
|
||||
isBackButtonHidden: false,
|
||||
triggers: [],
|
||||
displayPercentage: null,
|
||||
delay: 0,
|
||||
projectOverwrites: null,
|
||||
};
|
||||
|
||||
const mockTransformedSurvey: TJsEnvironmentStateSurvey = {
|
||||
id: "survey-1",
|
||||
welcomeCard: { enabled: false } as TJsEnvironmentStateSurvey["welcomeCard"],
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
variables: [],
|
||||
type: "app",
|
||||
showLanguageSwitch: false,
|
||||
languages: [],
|
||||
endings: [],
|
||||
autoClose: null,
|
||||
styling: null,
|
||||
status: "inProgress",
|
||||
recaptcha: null,
|
||||
segment: null,
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
displayOption: "displayOnce",
|
||||
hiddenFields: { enabled: false },
|
||||
isBackButtonHidden: false,
|
||||
triggers: [],
|
||||
displayPercentage: null,
|
||||
delay: 0,
|
||||
projectOverwrites: null,
|
||||
};
|
||||
|
||||
describe("getSurveysForEnvironmentState", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
vi.mocked(validateInputs).mockReturnValue([environmentId]); // Assume validation passes
|
||||
vi.mocked(transformPrismaSurvey).mockReturnValue(mockTransformedSurvey);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return transformed surveys on successful fetch", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurvey]);
|
||||
|
||||
const result = await getSurveysForEnvironmentState(environmentId);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
select: expect.any(Object), // Check if select is called, specific fields are in the original code
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
|
||||
expect(result).toEqual([mockTransformedSurvey]);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return an empty array if no surveys are found", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getSurveysForEnvironmentState(environmentId);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(transformPrismaSurvey).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys for environment state");
|
||||
});
|
||||
|
||||
test("should rethrow unknown errors", async () => {
|
||||
const unknownError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(unknownError);
|
||||
|
||||
await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(unknownError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,9 @@ 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 { updateResponse } from "@/lib/response/service";
|
||||
import { getResponse, 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";
|
||||
@@ -48,10 +49,9 @@ export const PUT = async (
|
||||
);
|
||||
}
|
||||
|
||||
// update response
|
||||
let response;
|
||||
try {
|
||||
response = await updateResponse(responseId, inputValidation.data);
|
||||
response = await getResponse(responseId);
|
||||
} catch (error) {
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
@@ -66,27 +66,64 @@ export const PUT = async (
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(response.data, survey.questions)) {
|
||||
if (!validateFileUploads(inputValidation.data.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,
|
||||
response: updatedResponse,
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
if (updatedResponse.finished) {
|
||||
// send response to pipeline
|
||||
// don't await to not block the response
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response,
|
||||
response: updatedResponse,
|
||||
});
|
||||
}
|
||||
return responses.successResponse({}, true);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getContact, getContactByUserId } from "./contact";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock cache module
|
||||
vi.mock("@/lib/cache");
|
||||
|
||||
// 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 mockContactId = "test-contact-id";
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockUserId = "test-user-id";
|
||||
|
||||
describe("Contact API Lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("getContact", () => {
|
||||
test("should return contact if found", async () => {
|
||||
const mockContactData = { id: mockContactId };
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData);
|
||||
|
||||
const contact = await getContact(mockContactId);
|
||||
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(contact).toEqual(mockContactData);
|
||||
});
|
||||
|
||||
test("should return null if contact not found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
|
||||
|
||||
const contact = await getContact(mockContactId);
|
||||
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(contact).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.contact.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getContact(mockContactId)).rejects.toThrow(DatabaseError);
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
test("should return contact with formatted attributes if found", async () => {
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "userId" }, value: mockUserId },
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData);
|
||||
|
||||
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
value: mockUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(contact).toEqual({
|
||||
id: mockContactId,
|
||||
attributes: {
|
||||
userId: mockUserId,
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if contact not found by userId", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
value: mockUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(contact).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
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 { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse } from "./response";
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockIsFormbricksCloud;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/posthogServer", () => ({
|
||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/cache", () => ({
|
||||
responseCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/utils", () => ({
|
||||
calculateTtcTotal: vi.fn((ttc) => ttc),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/responseNote/cache", () => ({
|
||||
responseNoteCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telemetry", () => ({
|
||||
captureTelemetry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./contact", () => ({
|
||||
getContactByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const surveyId = "test-survey-id";
|
||||
const organizationId = "test-organization-id";
|
||||
const responseId = "test-response-id";
|
||||
|
||||
const mockOrganization = {
|
||||
id: organizationId,
|
||||
name: "Test Org",
|
||||
billing: {
|
||||
limits: { monthly: { responses: 100 } },
|
||||
plan: "free",
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponseInput: TResponseInput = {
|
||||
environmentId,
|
||||
surveyId,
|
||||
userId: null,
|
||||
finished: false,
|
||||
data: { question1: "answer1" },
|
||||
meta: { source: "web" },
|
||||
ttc: { question1: 1000 },
|
||||
};
|
||||
|
||||
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: null,
|
||||
displayId: null,
|
||||
tags: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
describe("createResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any);
|
||||
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
|
||||
test("should handle finished response and calculate TTC", async () => {
|
||||
const finishedInput = { ...mockResponseInput, finished: true };
|
||||
await createResponse(finishedInput);
|
||||
expect(calculateTtcTotal).toHaveBeenCalledWith(mockResponseInput.ttc);
|
||||
expect(prisma.response.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ finished: true }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
|
||||
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 Prisma 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);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getUploadSignedUrl } from "@/lib/storage/service";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { uploadPrivateFile } from "./uploadPrivateFile";
|
||||
|
||||
vi.mock("@/lib/storage/service", () => ({
|
||||
getUploadSignedUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("uploadPrivateFile", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return a success response with signed URL details when getUploadSignedUrl successfully generates a signed URL", async () => {
|
||||
const mockSignedUrlResponse = {
|
||||
signedUrl: "mocked-signed-url",
|
||||
presignedFields: { field1: "value1" },
|
||||
fileUrl: "mocked-file-url",
|
||||
};
|
||||
|
||||
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
|
||||
|
||||
const fileName = "test-file.txt";
|
||||
const environmentId = "test-env-id";
|
||||
const fileType = "text/plain";
|
||||
|
||||
const result = await uploadPrivateFile(fileName, environmentId, fileType);
|
||||
const resultData = await result.json();
|
||||
|
||||
expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false);
|
||||
|
||||
expect(resultData).toEqual({
|
||||
data: mockSignedUrlResponse,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return a success response when isBiggerFileUploadAllowed is true and getUploadSignedUrl successfully generates a signed URL", async () => {
|
||||
const mockSignedUrlResponse = {
|
||||
signedUrl: "mocked-signed-url",
|
||||
presignedFields: { field1: "value1" },
|
||||
fileUrl: "mocked-file-url",
|
||||
};
|
||||
|
||||
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
|
||||
|
||||
const fileName = "test-file.txt";
|
||||
const environmentId = "test-env-id";
|
||||
const fileType = "text/plain";
|
||||
const isBiggerFileUploadAllowed = true;
|
||||
|
||||
const result = await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
|
||||
const resultData = await result.json();
|
||||
|
||||
expect(getUploadSignedUrl).toHaveBeenCalledWith(
|
||||
fileName,
|
||||
environmentId,
|
||||
fileType,
|
||||
"private",
|
||||
isBiggerFileUploadAllowed
|
||||
);
|
||||
|
||||
expect(resultData).toEqual({
|
||||
data: mockSignedUrlResponse,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return an internal server error response when getUploadSignedUrl throws an error", async () => {
|
||||
vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("S3 unavailable"));
|
||||
|
||||
const fileName = "test-file.txt";
|
||||
const environmentId = "test-env-id";
|
||||
const fileType = "text/plain";
|
||||
|
||||
const result = await uploadPrivateFile(fileName, environmentId, fileType);
|
||||
|
||||
expect(result.status).toBe(500);
|
||||
const resultData = await result.json();
|
||||
expect(resultData).toEqual({
|
||||
code: "internal_server_error",
|
||||
details: {},
|
||||
message: "Internal server error",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return an internal server error response when fileName has no extension", async () => {
|
||||
vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("File extension not found"));
|
||||
|
||||
const fileName = "test-file";
|
||||
const environmentId = "test-env-id";
|
||||
const fileType = "text/plain";
|
||||
|
||||
const result = await uploadPrivateFile(fileName, environmentId, fileType);
|
||||
const resultData = await result.json();
|
||||
|
||||
expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false);
|
||||
expect(result.status).toBe(500);
|
||||
expect(resultData).toEqual({
|
||||
code: "internal_server_error",
|
||||
details: {},
|
||||
message: "Internal server error",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { mockUser } from "@/modules/auth/lib/mock-data";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
describe("getSessionUser", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should return the user object when valid req and res are provided", async () => {
|
||||
const mockReq = {} as NextApiRequest;
|
||||
const mockRes = {} as NextApiResponse;
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: mockUser });
|
||||
|
||||
const user = await getSessionUser(mockReq, mockRes);
|
||||
|
||||
expect(user).toEqual(mockUser);
|
||||
expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions);
|
||||
});
|
||||
|
||||
test("should return the user object when neither req nor res are provided", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: mockUser });
|
||||
|
||||
const user = await getSessionUser();
|
||||
|
||||
expect(user).toEqual(mockUser);
|
||||
expect(getServerSession).toHaveBeenCalledWith(authOptions);
|
||||
});
|
||||
|
||||
test("should return undefined if no session exists", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const user = await getSessionUser();
|
||||
|
||||
expect(user).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return null when session exists and user property is null", async () => {
|
||||
const mockReq = {} as NextApiRequest;
|
||||
const mockRes = {} as NextApiResponse;
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: null });
|
||||
|
||||
const user = await getSessionUser(mockReq, mockRes);
|
||||
|
||||
expect(user).toBeNull();
|
||||
expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache");
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const userId = "test-user-id";
|
||||
const contactId = "test-contact-id";
|
||||
|
||||
const mockContactDbData = {
|
||||
id: contactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "userId" }, value: userId },
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "plan" }, value: "premium" },
|
||||
],
|
||||
};
|
||||
|
||||
const expectedContactAttributes: TContactAttributes = {
|
||||
userId: userId,
|
||||
email: "test@example.com",
|
||||
plan: "premium",
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
test("should return contact with attributes when found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData);
|
||||
|
||||
const contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(contact).toEqual({
|
||||
id: contactId,
|
||||
attributes: expectedContactAttributes,
|
||||
});
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getContactByUserIdForResponsesApi-${environmentId}-${userId}`],
|
||||
{
|
||||
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("should return null when contact is not found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(contact).toBeNull();
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getContactByUserIdForResponsesApi-${environmentId}-${userId}`],
|
||||
{
|
||||
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { responseNoteCache } from "@/lib/responseNote/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
||||
import { getContactByUserId } from "./contact";
|
||||
import { createResponse, getResponsesByEnvironmentIds } from "./response";
|
||||
|
||||
// Mock Data
|
||||
const environmentId = "test-environment-id";
|
||||
const organizationId = "test-organization-id";
|
||||
const mockUserId = "test-user-id";
|
||||
const surveyId = "test-survey-id";
|
||||
const displayId = "test-display-id";
|
||||
const responseId = "test-response-id";
|
||||
|
||||
const mockOrganization = {
|
||||
id: organizationId,
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { plan: "free", limits: { monthly: { responses: null } } } as any, // Default no limit
|
||||
} as unknown as Organization;
|
||||
|
||||
const mockResponseInput: TResponseInput = {
|
||||
environmentId,
|
||||
surveyId,
|
||||
displayId,
|
||||
finished: true,
|
||||
data: { q1: "answer1" },
|
||||
meta: { userAgent: { browser: "test-browser" } },
|
||||
ttc: { q1: 5 },
|
||||
language: "en",
|
||||
};
|
||||
|
||||
const mockResponseInputWithUserId: TResponseInput = {
|
||||
...mockResponseInput,
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
// Prisma response structure (simplified)
|
||||
const mockResponsePrisma = {
|
||||
id: responseId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId,
|
||||
finished: true,
|
||||
endingId: null,
|
||||
data: { q1: "answer1" },
|
||||
meta: { userAgent: { browser: "test-browser" } },
|
||||
ttc: { q1: 5, total: 10 }, // Assume calculateTtcTotal adds 'total'
|
||||
variables: {},
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: "en",
|
||||
displayId,
|
||||
contact: null, // Prisma relation
|
||||
tags: [], // Prisma relation
|
||||
notes: [], // Prisma relation
|
||||
} as unknown as ResponsePrisma & { contact: any; tags: any[]; notes: any[] }; // Adjust type as needed
|
||||
|
||||
const mockResponse: TResponse = {
|
||||
id: responseId,
|
||||
createdAt: mockResponsePrisma.createdAt,
|
||||
updatedAt: mockResponsePrisma.updatedAt,
|
||||
surveyId,
|
||||
finished: true,
|
||||
endingId: null,
|
||||
data: { q1: "answer1" },
|
||||
meta: { userAgent: { browser: "test-browser" } },
|
||||
ttc: { q1: 5, total: 10 },
|
||||
variables: {},
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: "en",
|
||||
displayId,
|
||||
contact: null, // Transformed structure
|
||||
tags: [], // Transformed structure
|
||||
notes: [], // Transformed structure
|
||||
};
|
||||
|
||||
const mockEnvironmentIds = [environmentId, "env-2"];
|
||||
const mockLimit = 10;
|
||||
const mockOffset = 5;
|
||||
|
||||
const mockResponsesPrisma = [mockResponsePrisma, { ...mockResponsePrisma, id: "response-2" }];
|
||||
const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response-2" }];
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/cache");
|
||||
vi.mock("@/lib/response/service");
|
||||
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(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger");
|
||||
vi.mock("./contact");
|
||||
|
||||
describe("Response Lib Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// No need to mock IS_FORMBRICKS_CLOUD here anymore unless specifically changing it from the default
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponse", () => {
|
||||
test("should create a response successfully with userId", async () => {
|
||||
const mockContact = { id: "contact1", attributes: { userId: mockUserId } };
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(prisma.response.create).mockResolvedValue({
|
||||
...mockResponsePrisma,
|
||||
});
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
|
||||
const response = await createResponse(mockResponseInputWithUserId);
|
||||
|
||||
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
|
||||
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, mockUserId);
|
||||
expect(prisma.response.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
contact: { connect: { id: mockContact.id } },
|
||||
contactAttributes: mockContact.attributes,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: mockContact.id,
|
||||
userId: mockUserId,
|
||||
})
|
||||
);
|
||||
expect(responseNoteCache.revalidate).toHaveBeenCalled();
|
||||
expect(response.contact).toEqual({ id: mockContact.id, userId: mockUserId });
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
|
||||
expect(prisma.response.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2002",
|
||||
clientVersion: "2.0",
|
||||
});
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
|
||||
});
|
||||
|
||||
test("should handle generic errors", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
describe("Cloud specific tests", () => {
|
||||
test("should check response limit and send event if limit reached", async () => {
|
||||
// IS_FORMBRICKS_CLOUD is true by default from the top-level mock
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should check response limit and not send event if limit not reached", async () => {
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
const posthogError = new Error("Posthog error");
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
// Expecting successful response creation despite PostHog error
|
||||
const response = await createResponse(mockResponseInput);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
expect(response).toEqual(mockResponse); // Should still return the created response
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesByEnvironmentIds", () => {
|
||||
test("should return responses successfully", async () => {
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma);
|
||||
vi.mocked(getResponseContact).mockReturnValue(null); // Assume no contact for simplicity
|
||||
|
||||
const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.response.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
survey: {
|
||||
environmentId: { in: mockEnvironmentIds },
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
})
|
||||
);
|
||||
expect(getResponseContact).toHaveBeenCalledTimes(mockResponsesPrisma.length);
|
||||
expect(responses).toEqual(mockTransformedResponses);
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return responses with limit and offset", async () => {
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma);
|
||||
vi.mocked(getResponseContact).mockReturnValue(null);
|
||||
|
||||
await getResponsesByEnvironmentIds(mockEnvironmentIds, mockLimit, mockOffset);
|
||||
|
||||
expect(prisma.response.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: mockLimit,
|
||||
skip: mockOffset,
|
||||
})
|
||||
);
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return empty array if no responses found", async () => {
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
||||
|
||||
const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds);
|
||||
|
||||
expect(responses).toEqual([]);
|
||||
expect(prisma.response.findMany).toHaveBeenCalled();
|
||||
expect(getResponseContact).not.toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2002",
|
||||
clientVersion: "2.0",
|
||||
});
|
||||
vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle generic errors", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.response.findMany).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(genericError);
|
||||
expect(cache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getUploadSignedUrl } from "@/lib/storage/service";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { getSignedUrlForPublicFile } from "./getSignedUrl";
|
||||
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
responses: {
|
||||
successResponse: vi.fn((data) => ({ data })),
|
||||
internalServerErrorResponse: vi.fn((message) => ({ message })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/storage/service", () => ({
|
||||
getUploadSignedUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getSignedUrlForPublicFile", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return success response with signed URL data", async () => {
|
||||
const mockFileName = "test.jpg";
|
||||
const mockEnvironmentId = "env123";
|
||||
const mockFileType = "image/jpeg";
|
||||
const mockSignedUrlResponse = {
|
||||
signedUrl: "http://example.com/signed-url",
|
||||
signingData: { signature: "sig", timestamp: 123, uuid: "uuid" },
|
||||
updatedFileName: "test--fid--uuid.jpg",
|
||||
fileUrl: "http://example.com/file-url",
|
||||
};
|
||||
|
||||
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
|
||||
|
||||
const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType);
|
||||
|
||||
expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public");
|
||||
expect(responses.successResponse).toHaveBeenCalledWith(mockSignedUrlResponse);
|
||||
expect(result).toEqual({ data: mockSignedUrlResponse });
|
||||
});
|
||||
|
||||
test("should return internal server error response when getUploadSignedUrl throws an error", async () => {
|
||||
const mockFileName = "test.png";
|
||||
const mockEnvironmentId = "env456";
|
||||
const mockFileType = "image/png";
|
||||
const mockError = new Error("Failed to get signed URL");
|
||||
|
||||
vi.mocked(getUploadSignedUrl).mockRejectedValue(mockError);
|
||||
|
||||
const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType);
|
||||
|
||||
expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public");
|
||||
expect(responses.internalServerErrorResponse).toHaveBeenCalledWith("Internal server error");
|
||||
expect(result).toEqual({ message: "Internal server error" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
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 { DatabaseError } from "@formbricks/types/errors";
|
||||
import { deleteSurvey } from "./surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache/segment", () => ({
|
||||
segmentCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/response/cache", () => ({
|
||||
responseCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/survey/cache", () => ({
|
||||
surveyCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
segment: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
|
||||
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
|
||||
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
|
||||
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
|
||||
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
|
||||
|
||||
const mockDeletedSurveyAppPrivateSegment = {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
type: "app",
|
||||
segment: { id: segmentId, isPrivate: true },
|
||||
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
|
||||
resultShareKey: "shareKey123",
|
||||
};
|
||||
|
||||
const mockDeletedSurveyLink = {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
type: "link",
|
||||
segment: null,
|
||||
triggers: [],
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
describe("deleteSurvey", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should delete a link survey without a segment and revalidate caches", async () => {
|
||||
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
|
||||
|
||||
const deletedSurvey = await deleteSurvey(surveyId);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
|
||||
expect(prisma.survey.delete).toHaveBeenCalledWith({
|
||||
where: { id: surveyId },
|
||||
include: {
|
||||
segment: true,
|
||||
triggers: { include: { actionClass: true } },
|
||||
},
|
||||
});
|
||||
expect(prisma.segment.delete).not.toHaveBeenCalled();
|
||||
expect(segmentCache.revalidate).not.toHaveBeenCalled(); // No segment to revalidate
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({ surveyId, environmentId });
|
||||
expect(surveyCache.revalidate).toHaveBeenCalledTimes(1); // Only for surveyId
|
||||
expect(surveyCache.revalidate).toHaveBeenCalledWith({
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
resultShareKey: undefined,
|
||||
});
|
||||
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
|
||||
expect(prisma.segment.delete).not.toHaveBeenCalled();
|
||||
expect(segmentCache.revalidate).not.toHaveBeenCalled();
|
||||
expect(responseCache.revalidate).not.toHaveBeenCalled();
|
||||
expect(surveyCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
|
||||
code: "P2003",
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
|
||||
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
|
||||
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
|
||||
// Caches might have been partially revalidated before the error
|
||||
});
|
||||
|
||||
test("should handle generic errors during deletion", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled(); // Should not log generic errors here
|
||||
expect(prisma.segment.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw validation error for invalid surveyId", async () => {
|
||||
const invalidSurveyId = "invalid-id";
|
||||
const validationError = new Error("Validation failed");
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
throw validationError;
|
||||
});
|
||||
|
||||
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
|
||||
expect(prisma.survey.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { selectSurvey } from "@/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "./surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/survey/cache");
|
||||
vi.mock("@/lib/survey/utils");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger");
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: vi.fn((fn) => fn), // Mock reactCache to just execute the function
|
||||
};
|
||||
});
|
||||
|
||||
const environmentId1 = "env1";
|
||||
const environmentId2 = "env2";
|
||||
const surveyId1 = "survey1";
|
||||
const surveyId2 = "survey2";
|
||||
const surveyId3 = "survey3";
|
||||
|
||||
const mockSurveyPrisma1 = {
|
||||
id: surveyId1,
|
||||
environmentId: environmentId1,
|
||||
name: "Survey 1",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const mockSurveyPrisma2 = {
|
||||
id: surveyId2,
|
||||
environmentId: environmentId1,
|
||||
name: "Survey 2",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const mockSurveyPrisma3 = {
|
||||
id: surveyId3,
|
||||
environmentId: environmentId2,
|
||||
name: "Survey 3",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockSurveyTransformed1: TSurvey = {
|
||||
...mockSurveyPrisma1,
|
||||
displayPercentage: null,
|
||||
segment: null,
|
||||
} as TSurvey;
|
||||
const mockSurveyTransformed2: TSurvey = {
|
||||
...mockSurveyPrisma2,
|
||||
displayPercentage: null,
|
||||
segment: null,
|
||||
} as TSurvey;
|
||||
const mockSurveyTransformed3: TSurvey = {
|
||||
...mockSurveyPrisma3,
|
||||
displayPercentage: null,
|
||||
segment: null,
|
||||
} as TSurvey;
|
||||
|
||||
describe("getSurveys (Management API)", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Mock the cache function to simply execute the underlying function
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => ({
|
||||
...survey,
|
||||
displayPercentage: null,
|
||||
segment: null,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return surveys for a single environment ID with limit and offset", async () => {
|
||||
const limit = 1;
|
||||
const offset = 1;
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockSurveyPrisma2]);
|
||||
|
||||
const surveys = await getSurveys([environmentId1], limit, offset);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[[environmentId1], expect.any(Object)],
|
||||
[limit, expect.any(Object)],
|
||||
[offset, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId: { in: [environmentId1] } },
|
||||
select: selectSurvey,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledTimes(1);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyPrisma2);
|
||||
expect(surveys).toEqual([mockSurveyTransformed2]);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should return surveys for multiple environment IDs without limit and offset", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
mockSurveyPrisma1,
|
||||
mockSurveyPrisma2,
|
||||
mockSurveyPrisma3,
|
||||
]);
|
||||
|
||||
const surveys = await getSurveys([environmentId1, environmentId2]);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[[environmentId1, environmentId2], expect.any(Object)],
|
||||
[undefined, expect.any(Object)],
|
||||
[undefined, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId: { in: [environmentId1, environmentId2] } },
|
||||
select: selectSurvey,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledTimes(3);
|
||||
expect(surveys).toEqual([mockSurveyTransformed1, mockSurveyTransformed2, mockSurveyTransformed3]);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should return an empty array if no surveys are found", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
|
||||
|
||||
const surveys = await getSurveys([environmentId1]);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalled();
|
||||
expect(transformPrismaSurvey).not.toHaveBeenCalled();
|
||||
expect(surveys).toEqual([]);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2021",
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getSurveys([environmentId1])).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys");
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle generic errors", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getSurveys([environmentId1])).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should throw validation error for invalid input", async () => {
|
||||
const invalidEnvId = "invalid-env";
|
||||
const validationError = new Error("Validation failed");
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
throw validationError;
|
||||
});
|
||||
|
||||
await expect(getSurveys([invalidEnvId])).rejects.toThrow(validationError);
|
||||
expect(prisma.survey.findMany).not.toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalledTimes(1); // Cache wrapper is still called
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ValidationError } from "@formbricks/types/errors";
|
||||
import { deleteWebhook } from "./webhook";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
tag: {
|
||||
byId: () => "mockTag",
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
ValidationError: class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("deleteWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should delete the webhook and return the deleted webhook object when provided with a valid webhook ID", async () => {
|
||||
const mockedWebhook: Webhook = {
|
||||
id: "test-webhook-id",
|
||||
url: "https://example.com",
|
||||
name: "Test Webhook",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
source: "user",
|
||||
environmentId: "test-environment-id",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook);
|
||||
|
||||
const deletedWebhook = await deleteWebhook("test-webhook-id");
|
||||
|
||||
expect(deletedWebhook).toEqual(mockedWebhook);
|
||||
expect(prisma.webhook.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "test-webhook-id",
|
||||
},
|
||||
});
|
||||
expect(webhookCache.revalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should delete the webhook and call webhookCache.revalidate with correct parameters", async () => {
|
||||
const mockedWebhook: Webhook = {
|
||||
id: "test-webhook-id",
|
||||
url: "https://example.com",
|
||||
name: "Test Webhook",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
source: "user",
|
||||
environmentId: "test-environment-id",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook);
|
||||
|
||||
const deletedWebhook = await deleteWebhook("test-webhook-id");
|
||||
|
||||
expect(deletedWebhook).toEqual(mockedWebhook);
|
||||
expect(prisma.webhook.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "test-webhook-id",
|
||||
},
|
||||
});
|
||||
expect(webhookCache.revalidate).toHaveBeenCalledWith({
|
||||
id: mockedWebhook.id,
|
||||
environmentId: mockedWebhook.environmentId,
|
||||
source: mockedWebhook.source,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw an error when called with an invalid webhook ID format", async () => {
|
||||
const { validateInputs } = await import("@/lib/utils/validate");
|
||||
(validateInputs as any).mockImplementation(() => {
|
||||
throw new ValidationError("Validation failed");
|
||||
});
|
||||
|
||||
await expect(deleteWebhook("invalid-id")).rejects.toThrow(ValidationError);
|
||||
|
||||
expect(prisma.webhook.delete).not.toHaveBeenCalled();
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
|
||||
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma, WebhookSource } from "@prisma/client";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("createWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should create a webhook and revalidate the cache when provided with valid input data", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1", "survey2"],
|
||||
};
|
||||
|
||||
const createdWebhook = {
|
||||
id: "webhook-id",
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user" as WebhookSource,
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1", "survey2"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any;
|
||||
|
||||
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
|
||||
|
||||
const result = await createWebhook(webhookInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
|
||||
expect(prisma.webhook.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
url: webhookInput.url,
|
||||
name: webhookInput.name,
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(webhookCache.revalidate).toHaveBeenCalledWith({
|
||||
id: createdWebhook.id,
|
||||
environmentId: createdWebhook.environmentId,
|
||||
source: createdWebhook.source,
|
||||
});
|
||||
|
||||
expect(result).toEqual(createdWebhook);
|
||||
});
|
||||
|
||||
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
|
||||
const invalidWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: 123, // Invalid URL
|
||||
source: "user" as WebhookSource,
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1", "survey2"],
|
||||
};
|
||||
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
throw new ValidationError("Validation failed");
|
||||
});
|
||||
|
||||
await expect(createWebhook(invalidWebhookInput as any)).rejects.toThrowError(ValidationError);
|
||||
});
|
||||
|
||||
test("should throw a DatabaseError if a PrismaClientKnownRequestError occurs", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1", "survey2"],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
|
||||
});
|
||||
|
||||
test("should call webhookCache.revalidate with the correct parameters after successfully creating a webhook", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1"],
|
||||
};
|
||||
|
||||
const createdWebhook = {
|
||||
id: "webhook123",
|
||||
environmentId: "env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any;
|
||||
|
||||
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
|
||||
|
||||
await createWebhook(webhookInput);
|
||||
|
||||
expect(webhookCache.revalidate).toHaveBeenCalledWith({
|
||||
id: createdWebhook.id,
|
||||
environmentId: createdWebhook.environmentId,
|
||||
source: createdWebhook.source,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw a DatabaseError when provided with invalid surveyIds", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["invalid-survey-id"],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Foreign key constraint violation"));
|
||||
|
||||
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
|
||||
});
|
||||
|
||||
test("should handle edge case URLs that are technically valid but problematic", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "http://localhost:3000", // Example of a potentially problematic URL
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1", "survey2"],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new DatabaseError("Invalid URL"));
|
||||
|
||||
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(prisma.webhook.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
url: webhookInput.url,
|
||||
name: webhookInput.name,
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(webhookCache.revalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,12 @@ 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(),
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -80,6 +81,23 @@ 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"] = {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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();
|
||||
};
|
||||
+18
-20
@@ -3,9 +3,12 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { env } from "./env";
|
||||
|
||||
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "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);
|
||||
|
||||
export const IS_PRODUCTION = env.NODE_ENV === "production";
|
||||
|
||||
export const IS_DEVELOPMENT = env.NODE_ENV === "development";
|
||||
|
||||
export const E2E_TESTING = env.E2E_TESTING === "1";
|
||||
|
||||
// URLs
|
||||
export const WEBAPP_URL =
|
||||
@@ -30,13 +33,11 @@ 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 ? 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 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 SAML_XML_DIR = "./saml-connection";
|
||||
|
||||
export const GITHUB_ID = env.GITHUB_ID;
|
||||
@@ -60,7 +61,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 = env.SIGNUP_DISABLED !== "1";
|
||||
export const SIGNUP_ENABLED = IS_FORMBRICKS_CLOUD || IS_DEVELOPMENT || E2E_TESTING;
|
||||
export const EMAIL_AUTH_ENABLED = env.EMAIL_AUTH_DISABLED !== "1";
|
||||
export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
|
||||
|
||||
@@ -96,9 +97,11 @@ 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 DEFAULT_ORGANIZATION_ID = env.DEFAULT_ORGANIZATION_ID;
|
||||
export const DEFAULT_ORGANIZATION_ROLE = env.DEFAULT_ORGANIZATION_ROLE;
|
||||
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 SLACK_MESSAGE_LIMIT = 2995;
|
||||
export const GOOGLE_SHEET_MESSAGE_LIMIT = 49995;
|
||||
@@ -112,7 +115,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
|
||||
@@ -196,7 +199,6 @@ 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;
|
||||
@@ -216,7 +218,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 as const;
|
||||
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
export const AVAILABLE_LOCALES: TUserLocale[] = ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"];
|
||||
@@ -277,10 +279,6 @@ 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";
|
||||
|
||||
+4
-14
@@ -17,19 +17,13 @@ export const env = createEnv({
|
||||
DATABASE_URL: z.string().url(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
DOCKER_CRON_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
DEFAULT_ORGANIZATION_ID: z.string().optional(),
|
||||
DEFAULT_ORGANIZATION_ROLE: z.enum(["owner", "manager", "member", "billing"]).optional(),
|
||||
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
|
||||
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).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(),
|
||||
@@ -81,7 +75,6 @@ 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(),
|
||||
@@ -131,16 +124,14 @@ export const env = createEnv({
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEFAULT_ORGANIZATION_ID: process.env.DEFAULT_ORGANIZATION_ID,
|
||||
DEFAULT_ORGANIZATION_ROLE: process.env.DEFAULT_ORGANIZATION_ROLE,
|
||||
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
|
||||
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
|
||||
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,
|
||||
@@ -184,7 +175,6 @@ 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,
|
||||
|
||||
@@ -63,18 +63,35 @@ export const createMembership = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
if (existingMembership && existingMembership.role === data.role) {
|
||||
return existingMembership;
|
||||
}
|
||||
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
organizationId,
|
||||
accepted: data.accepted,
|
||||
role: data.role as TMembership["role"],
|
||||
},
|
||||
});
|
||||
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"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
organizationCache.revalidate({
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -428,7 +428,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
});
|
||||
|
||||
segmentCache.revalidate({ id: updatedSegment.id, environmentId: updatedSegment.environmentId });
|
||||
updatedSegment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
|
||||
updatedSegment.surveys.forEach((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.map((survey) => surveyCache.revalidate({ id: survey.id }));
|
||||
segment.surveys.forEach((survey) => surveyCache.revalidate({ id: survey.id }));
|
||||
surveyCache.revalidate({ environmentId: segment.environmentId });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const isValidEmail = (email): boolean => {
|
||||
const regex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
|
||||
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;
|
||||
return regex.test(email);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"auth": {
|
||||
"continue_with_azure": "Login mit Azure",
|
||||
"continue_with_azure": "Weiter mit Microsoft",
|
||||
"continue_with_email": "Login mit E-Mail",
|
||||
"continue_with_github": "Login mit GitHub",
|
||||
"continue_with_google": "Login mit Google",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"auth": {
|
||||
"continue_with_azure": "Continue with Azure",
|
||||
"continue_with_azure": "Continue with Microsoft",
|
||||
"continue_with_email": "Continue with Email",
|
||||
"continue_with_github": "Continue with GitHub",
|
||||
"continue_with_google": "Continue with Google",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"auth": {
|
||||
"continue_with_azure": "Continuer avec Azure",
|
||||
"continue_with_azure": "Continuer avec Microsoft",
|
||||
"continue_with_email": "Continuer avec l'e-mail",
|
||||
"continue_with_github": "Continuer avec GitHub",
|
||||
"continue_with_google": "Continuer avec Google",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"auth": {
|
||||
"continue_with_azure": "Continuar com Azure",
|
||||
"continue_with_azure": "Continuar com Microsoft",
|
||||
"continue_with_email": "Continuar com o Email",
|
||||
"continue_with_github": "Continuar com o GitHub",
|
||||
"continue_with_google": "Continuar com o Google",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"auth": {
|
||||
"continue_with_azure": "Continuar com Azure",
|
||||
"continue_with_azure": "Continuar com Microsoft",
|
||||
"continue_with_email": "Continuar com Email",
|
||||
"continue_with_github": "Continuar com GitHub",
|
||||
"continue_with_google": "Continuar com Google",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"auth": {
|
||||
"continue_with_azure": "使用 Azure 繼續",
|
||||
"continue_with_azure": "繼續使用 Microsoft",
|
||||
"continue_with_email": "使用電子郵件繼續",
|
||||
"continue_with_github": "使用 GitHub 繼續",
|
||||
"continue_with_google": "使用 Google 繼續",
|
||||
|
||||
@@ -26,7 +26,6 @@ describe("DeleteAccountModal", () => {
|
||||
const mockOrgs: TOrganization[] = [{ name: "Org1" }, { name: "Org2" }] as TOrganization[];
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockLogout = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -40,7 +39,6 @@ describe("DeleteAccountModal", () => {
|
||||
user={mockUser}
|
||||
isFormbricksCloud={false}
|
||||
organizationsWithSingleOwner={mockOrgs}
|
||||
formbricksLogout={mockLogout}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -56,7 +54,6 @@ describe("DeleteAccountModal", () => {
|
||||
user={mockUser}
|
||||
isFormbricksCloud={false}
|
||||
organizationsWithSingleOwner={[]}
|
||||
formbricksLogout={mockLogout}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -78,7 +75,6 @@ describe("DeleteAccountModal", () => {
|
||||
user={mockUser}
|
||||
isFormbricksCloud={false}
|
||||
organizationsWithSingleOwner={[]}
|
||||
formbricksLogout={mockLogout}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -90,7 +86,6 @@ describe("DeleteAccountModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteUserAction).toHaveBeenCalled();
|
||||
expect(mockLogout).toHaveBeenCalled();
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
@@ -114,7 +109,6 @@ describe("DeleteAccountModal", () => {
|
||||
user={mockUser}
|
||||
isFormbricksCloud={true}
|
||||
organizationsWithSingleOwner={[]}
|
||||
formbricksLogout={mockLogout}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -126,7 +120,6 @@ describe("DeleteAccountModal", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteUserAction).toHaveBeenCalled();
|
||||
expect(mockLogout).toHaveBeenCalled();
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: true });
|
||||
expect(window.location.replace).toHaveBeenCalled();
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
@@ -143,7 +136,6 @@ describe("DeleteAccountModal", () => {
|
||||
user={mockUser}
|
||||
isFormbricksCloud={false}
|
||||
organizationsWithSingleOwner={[]}
|
||||
formbricksLogout={mockLogout}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ interface DeleteAccountModalProps {
|
||||
user: TUser;
|
||||
isFormbricksCloud: boolean;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
formbricksLogout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const DeleteAccountModal = ({
|
||||
@@ -24,7 +23,6 @@ export const DeleteAccountModal = ({
|
||||
open,
|
||||
user,
|
||||
isFormbricksCloud,
|
||||
formbricksLogout,
|
||||
organizationsWithSingleOwner,
|
||||
}: DeleteAccountModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
@@ -38,7 +36,6 @@ export const DeleteAccountModal = ({
|
||||
try {
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
await formbricksLogout();
|
||||
// redirect to account deletion survey in Formbricks Cloud
|
||||
if (isFormbricksCloud) {
|
||||
await signOut({ redirect: true });
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -136,6 +137,28 @@ 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) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -90,6 +91,28 @@ 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);
|
||||
|
||||
@@ -177,6 +177,17 @@ 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,
|
||||
},
|
||||
|
||||
@@ -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, getOrganization } from "@/lib/organization/service";
|
||||
import { createOrganization } 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,7 +14,6 @@ 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({
|
||||
@@ -23,8 +22,6 @@ 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()
|
||||
@@ -92,42 +89,21 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
|
||||
|
||||
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
|
||||
await deleteInvite(invite.id);
|
||||
}
|
||||
// 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 });
|
||||
} 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,
|
||||
});
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
...user.notificationSettings,
|
||||
alert: { ...user.notificationSettings?.alert },
|
||||
weeklySummary: { ...user.notificationSettings?.weeklySummary },
|
||||
unsubscribedOrganizationIds: Array.from(
|
||||
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organizationId])
|
||||
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), organization.id])
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -119,8 +119,6 @@ const defaultProps = {
|
||||
isTurnstileConfigured: false,
|
||||
samlTenant: "",
|
||||
samlProduct: "",
|
||||
defaultOrganizationId: "org1",
|
||||
defaultOrganizationRole: "member",
|
||||
turnstileSiteKey: "dummy", // not used since isTurnstileConfigured is false
|
||||
} as const;
|
||||
|
||||
@@ -179,8 +177,6 @@ describe("SignupForm", () => {
|
||||
userLocale: defaultProps.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
|
||||
defaultOrganizationId: defaultProps.defaultOrganizationId,
|
||||
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
|
||||
turnstileToken: undefined,
|
||||
});
|
||||
});
|
||||
@@ -233,8 +229,6 @@ describe("SignupForm", () => {
|
||||
userLocale: props.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: true,
|
||||
defaultOrganizationId: props.defaultOrganizationId,
|
||||
defaultOrganizationRole: props.defaultOrganizationRole,
|
||||
turnstileToken: "test-turnstile-token",
|
||||
});
|
||||
});
|
||||
@@ -286,8 +280,6 @@ describe("SignupForm", () => {
|
||||
userLocale: props.userLocale,
|
||||
inviteToken: "",
|
||||
emailVerificationDisabled: true,
|
||||
defaultOrganizationId: props.defaultOrganizationId,
|
||||
defaultOrganizationRole: props.defaultOrganizationRole,
|
||||
turnstileToken: "test-turnstile-token",
|
||||
});
|
||||
});
|
||||
@@ -362,8 +354,6 @@ describe("SignupForm", () => {
|
||||
userLocale: defaultProps.userLocale,
|
||||
inviteToken: "token123",
|
||||
emailVerificationDisabled: defaultProps.emailVerificationDisabled,
|
||||
defaultOrganizationId: defaultProps.defaultOrganizationId,
|
||||
defaultOrganizationRole: defaultProps.defaultOrganizationRole,
|
||||
turnstileToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ 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";
|
||||
@@ -45,8 +44,6 @@ interface SignupFormProps {
|
||||
userLocale: TUserLocale;
|
||||
emailFromSearchParams?: string;
|
||||
emailVerificationDisabled: boolean;
|
||||
defaultOrganizationId?: string;
|
||||
defaultOrganizationRole?: TOrganizationRole;
|
||||
isSsoEnabled: boolean;
|
||||
samlSsoEnabled: boolean;
|
||||
isTurnstileConfigured: boolean;
|
||||
@@ -68,8 +65,6 @@ export const SignupForm = ({
|
||||
userLocale,
|
||||
emailFromSearchParams,
|
||||
emailVerificationDisabled,
|
||||
defaultOrganizationId,
|
||||
defaultOrganizationRole,
|
||||
isSsoEnabled,
|
||||
samlSsoEnabled,
|
||||
isTurnstileConfigured,
|
||||
@@ -116,8 +111,6 @@ export const SignupForm = ({
|
||||
userLocale,
|
||||
inviteToken: inviteToken || "",
|
||||
emailVerificationDisabled,
|
||||
defaultOrganizationId,
|
||||
defaultOrganizationRole,
|
||||
turnstileToken,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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,
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user