mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-03 03:10:00 -06:00
Compare commits
25 Commits
v3.9.0
...
nextjs-pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
649f28ff8d | ||
|
|
bc5a81d146 | ||
|
|
7dce35bde4 | ||
|
|
f30ebc32ec | ||
|
|
027bc20975 | ||
|
|
3b1cddb9ce | ||
|
|
bd22aaaa86 | ||
|
|
e0e42d2eed | ||
|
|
616210f1bf | ||
|
|
ff2e7f6cc7 | ||
|
|
d1ce037f7d | ||
|
|
91f87f4b7b | ||
|
|
61657b9f9a | ||
|
|
476d032642 | ||
|
|
7538e570c5 | ||
|
|
66fcf4b79b | ||
|
|
21371b1815 | ||
|
|
a53c13d6ed | ||
|
|
1a0c6e72b2 | ||
|
|
ba7c8b79b1 | ||
|
|
d7b504eed0 | ||
|
|
a1df10eb09 | ||
|
|
92be409d4f | ||
|
|
665c7c6bf1 | ||
|
|
6c2ff7ee08 |
13
.env.example
13
.env.example
@@ -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
|
||||
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -14,7 +14,8 @@ When generating test files inside the "/app/web" path, follow these rules:
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
|
||||
- When mocking data check if the properties added are part of the type of the object being mocked. Don't add properties that are not part of the type.
|
||||
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
|
||||
- When mocking data check if the properties added are part of the type of the object being mocked. Only specify known properties, don't use properties that are not part of the type.
|
||||
|
||||
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||
|
||||
|
||||
@@ -19,12 +19,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: Apply labels from linked issue to PR
|
||||
uses: actions/github-script@211cb3fefb35a799baa5156f9321bb774fe56294 # v5.2.0
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
4
.github/workflows/build-web.yml
vendored
4
.github/workflows/build-web.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/dependency-review.yml
vendored
4
.github/workflows/dependency-review.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/e2e.yml
vendored
4
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/labeler.yml
vendored
4
.github/workflows/labeler.yml
vendored
@@ -16,11 +16,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
|
||||
|
||||
- uses: actions/labeler@ac9175f8a1f3625fd0d4fb234536d26811351594 # v4.3.0
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
# https://github.com/actions/labeler/issues/442#issuecomment-1297359481
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/release-docker-github.yml
vendored
6
.github/workflows/release-docker-github.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
2
.github/workflows/release-helm-chart.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/scorecard.yml
vendored
4
.github/workflows/scorecard.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/semantic-pull-requests.yml
vendored
2
.github/workflows/semantic-pull-requests.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/sonarqube.yml
vendored
2
.github/workflows/sonarqube.yml
vendored
@@ -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
|
||||
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/tolgee.yml
vendored
2
.github/workflows/tolgee.yml
vendored
@@ -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,
|
||||
},
|
||||
};
|
||||
36
apps/demo/.gitignore
vendored
36
apps/demo/.gitignore
vendored
@@ -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(" ");
|
||||
}
|
||||
5
apps/demo/next-env.d.ts
vendored
5
apps/demo/next-env.d.ts
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SingleContactPage } from "@/modules/ee/contacts/[contactId]/page";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
// mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
ENCRYPTION_KEY: "test",
|
||||
ENTERPRISE_LICENSE_KEY: "test",
|
||||
GITHUB_ID: "test",
|
||||
GITHUB_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
test("should re-export SingleContactPage", () => {
|
||||
expect(Page).toBe(SingleContactPage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ContactsPage } from "@/modules/ee/contacts/page";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock the actual ContactsPage component
|
||||
vi.mock("@/modules/ee/contacts/page", () => ({
|
||||
ContactsPage: () => <div data-testid="contacts-page">Mock Contacts Page</div>,
|
||||
}));
|
||||
|
||||
describe("Contacts Page Re-export", () => {
|
||||
test("should re-export ContactsPage from the EE module", () => {
|
||||
// Assert that the default export 'Page' is the same as the mocked 'ContactsPage'
|
||||
expect(Page).toBe(ContactsPage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import SegmentsPageWrapper from "./page";
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/page", () => ({
|
||||
SegmentsPage: vi.fn(() => <div>SegmentsPageMock</div>),
|
||||
}));
|
||||
|
||||
describe("SegmentsPageWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the SegmentsPage component", () => {
|
||||
render(<SegmentsPageWrapper params={{ environmentId: "test-env" } as any} />);
|
||||
expect(screen.getByText("SegmentsPageMock")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { createActionClassAction } from "@/modules/survey/editor/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { getActiveInactiveSurveysAction } from "../actions";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
||||
ACTION_TYPE_ICON_LOOKUP: {
|
||||
noCode: <div>NoCodeIcon</div>,
|
||||
automatic: <div>AutomaticIcon</div>,
|
||||
code: <div>CodeIcon</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
convertDateTimeStringShort: (dateString: string) => `formatted-${dateString}`,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/strings", () => ({
|
||||
capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/actions", () => ({
|
||||
createActionClassAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, ...props }: any) => (
|
||||
<button onClick={onClick} data-variant={variant} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/error-component", () => ({
|
||||
ErrorComponent: () => <div>ErrorComponent</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children, ...props }: any) => <label {...props}>{children}</label>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/loading-spinner", () => ({
|
||||
LoadingSpinner: () => <div>LoadingSpinner</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
getActiveInactiveSurveysAction: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockActionClass = {
|
||||
id: "action1",
|
||||
createdAt: new Date("2023-01-01T10:00:00Z"),
|
||||
updatedAt: new Date("2023-01-10T11:00:00Z"),
|
||||
name: "Test Action",
|
||||
description: "Test Description",
|
||||
type: "noCode",
|
||||
environmentId: "env1_dev",
|
||||
noCodeConfig: {
|
||||
/* ... */
|
||||
} as any,
|
||||
} as unknown as TActionClass;
|
||||
|
||||
const mockEnvironmentDev = {
|
||||
id: "env1_dev",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockEnvironmentProd = {
|
||||
id: "env1_prod",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockOtherEnvActionClasses: TActionClass[] = [
|
||||
{
|
||||
id: "action2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Existing Action Prod",
|
||||
type: "noCode",
|
||||
environmentId: "env1_prod",
|
||||
} as unknown as TActionClass,
|
||||
{
|
||||
id: "action3",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Existing Code Action Prod",
|
||||
type: "code",
|
||||
key: "existing-key",
|
||||
environmentId: "env1_prod",
|
||||
} as unknown as TActionClass,
|
||||
];
|
||||
|
||||
describe("ActionActivityTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
|
||||
data: {
|
||||
activeSurveys: ["Active Survey 1"],
|
||||
inactiveSurveys: ["Inactive Survey 1", "Inactive Survey 2"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading state initially", () => {
|
||||
// Don't resolve the promise immediately
|
||||
vi.mocked(getActiveInactiveSurveysAction).mockReturnValue(new Promise(() => {}));
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("LoadingSpinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders error state if fetching surveys fails", async () => {
|
||||
vi.mocked(getActiveInactiveSurveysAction).mockResolvedValue({
|
||||
data: undefined,
|
||||
});
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
// Wait for the component to update after the promise resolves
|
||||
await screen.findByText("ErrorComponent");
|
||||
expect(screen.getByText("ErrorComponent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders survey lists and action details correctly", async () => {
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for loading to finish
|
||||
await screen.findByText("common.active_surveys");
|
||||
|
||||
// Check survey lists
|
||||
expect(screen.getByText("Active Survey 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive Survey 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive Survey 2")).toBeInTheDocument();
|
||||
|
||||
// Check action details
|
||||
// Use the actual Date.toString() output that the mock receives
|
||||
expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
|
||||
expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
|
||||
expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
|
||||
expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
|
||||
expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
|
||||
expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
|
||||
});
|
||||
|
||||
test("calls copyAction with correct data on button click", async () => {
|
||||
vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).toHaveBeenCalledTimes(1);
|
||||
// Include the extra properties that the component sends due to spreading mockActionClass
|
||||
const expectedActionInput = {
|
||||
...mockActionClass, // Spread the original object
|
||||
name: "Test Action", // Keep the original name as it doesn't conflict
|
||||
environmentId: "env1_prod", // Target environment ID
|
||||
};
|
||||
// Remove properties not expected by the action call itself, even if sent by component
|
||||
delete (expectedActionInput as any).id;
|
||||
delete (expectedActionInput as any).createdAt;
|
||||
delete (expectedActionInput as any).updatedAt;
|
||||
|
||||
// The assertion now checks against the structure sent by the component
|
||||
expect(createActionClassAction).toHaveBeenCalledWith({
|
||||
action: {
|
||||
...mockActionClass, // Include id, createdAt, updatedAt etc.
|
||||
name: "Test Action",
|
||||
environmentId: "env1_prod",
|
||||
},
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
|
||||
});
|
||||
|
||||
test("handles name conflict during copy", async () => {
|
||||
vi.mocked(createActionClassAction).mockResolvedValue({ data: { id: "newAction" } as any });
|
||||
const conflictingActionClass = { ...mockActionClass, name: "Existing Action Prod" };
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={conflictingActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The assertion now checks against the structure sent by the component
|
||||
expect(createActionClassAction).toHaveBeenCalledWith({
|
||||
action: {
|
||||
...conflictingActionClass, // Include id, createdAt, updatedAt etc.
|
||||
name: "Existing Action Prod (copy)",
|
||||
environmentId: "env1_prod",
|
||||
},
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_copied_successfully");
|
||||
});
|
||||
|
||||
test("handles key conflict during copy for 'code' type", async () => {
|
||||
const codeActionClass: TActionClass = {
|
||||
...mockActionClass,
|
||||
id: "codeAction1",
|
||||
type: "code",
|
||||
key: "existing-key", // Conflicting key
|
||||
noCodeConfig: {
|
||||
/* ... */
|
||||
} as any,
|
||||
};
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={codeActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.actions.action_with_key_already_exists");
|
||||
});
|
||||
|
||||
test("shows error if copy action fails server-side", async () => {
|
||||
vi.mocked(createActionClassAction).mockResolvedValue({ data: undefined });
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.actions.action_copy_failed");
|
||||
});
|
||||
|
||||
test("shows error and prevents copy if user is read-only", async () => {
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={mockActionClass}
|
||||
environmentId="env1_dev"
|
||||
environment={mockEnvironmentDev}
|
||||
otherEnvActionClasses={mockOtherEnvActionClasses}
|
||||
otherEnvironment={mockEnvironmentProd}
|
||||
isReadOnly={true} // Set to read-only
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByText("Copy to Production");
|
||||
const copyButton = screen.getByText("Copy to Production");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(createActionClassAction).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("common.you_are_not_authorised_to_perform_this_action");
|
||||
});
|
||||
|
||||
test("renders correct copy button text for production environment", async () => {
|
||||
render(
|
||||
<ActionActivityTab
|
||||
actionClass={{ ...mockActionClass, environmentId: "env1_prod" }}
|
||||
environmentId="env1_prod"
|
||||
environment={mockEnvironmentProd} // Current env is Production
|
||||
otherEnvActionClasses={[]} // Assume dev env has no actions for simplicity
|
||||
otherEnvironment={mockEnvironmentDev} // Target env is Development
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
await screen.findByText("Copy to Development");
|
||||
expect(screen.getByText("Copy to Development")).toBeInTheDocument();
|
||||
expect(screen.getByText("Production")).toBeInTheDocument(); // Environment text
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ActionClassesTable } from "./ActionClassesTable";
|
||||
|
||||
// Mock the ActionDetailModal
|
||||
vi.mock("./ActionDetailModal", () => ({
|
||||
ActionDetailModal: ({ open, actionClass, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="action-detail-modal">
|
||||
Modal for {actionClass.name}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{ id: "1", name: "Action 1", type: "noCode", environmentId: "env1" } as TActionClass,
|
||||
{ id: "2", name: "Action 2", type: "code", environmentId: "env1" } as TActionClass,
|
||||
];
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env1",
|
||||
name: "Test Environment",
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockOtherEnvironment: TEnvironment = {
|
||||
id: "env2",
|
||||
name: "Other Environment",
|
||||
type: "production",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockTableHeading = <div data-testid="table-heading">Table Heading</div>;
|
||||
const mockActionRows = mockActionClasses.map((action) => (
|
||||
<div key={action.id} data-testid={`action-row-${action.id}`}>
|
||||
{action.name} Row
|
||||
</div>
|
||||
));
|
||||
|
||||
describe("ActionClassesTable", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders table heading and action rows when actions exist", () => {
|
||||
render(
|
||||
<ActionClassesTable
|
||||
environmentId="env1"
|
||||
actionClasses={mockActionClasses}
|
||||
environment={mockEnvironment}
|
||||
isReadOnly={false}
|
||||
otherEnvActionClasses={[]}
|
||||
otherEnvironment={mockOtherEnvironment}>
|
||||
{[mockTableHeading, mockActionRows]}
|
||||
</ActionClassesTable>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("table-heading")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-row-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-row-2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No actions found")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders 'No actions found' message when no actions exist", () => {
|
||||
render(
|
||||
<ActionClassesTable
|
||||
environmentId="env1"
|
||||
actionClasses={[]}
|
||||
environment={mockEnvironment}
|
||||
isReadOnly={false}
|
||||
otherEnvActionClasses={[]}
|
||||
otherEnvironment={mockOtherEnvironment}>
|
||||
{[mockTableHeading, []]}
|
||||
</ActionClassesTable>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("table-heading")).toBeInTheDocument();
|
||||
expect(screen.getByText("No actions found")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-row-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens ActionDetailModal with correct action when a row is clicked", async () => {
|
||||
render(
|
||||
<ActionClassesTable
|
||||
environmentId="env1"
|
||||
actionClasses={mockActionClasses}
|
||||
environment={mockEnvironment}
|
||||
isReadOnly={false}
|
||||
otherEnvActionClasses={[]}
|
||||
otherEnvironment={mockOtherEnvironment}>
|
||||
{[mockTableHeading, mockActionRows]}
|
||||
</ActionClassesTable>
|
||||
);
|
||||
|
||||
// Modal should not be open initially
|
||||
expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
|
||||
|
||||
// Find the button wrapping the first action row
|
||||
const actionButton1 = screen.getByTitle("Action 1");
|
||||
await userEvent.click(actionButton1);
|
||||
|
||||
// Modal should now be open with the correct action name
|
||||
const modal = screen.getByTestId("action-detail-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(modal).toHaveTextContent("Modal for Action 1");
|
||||
|
||||
// Close the modal
|
||||
await userEvent.click(screen.getByText("Close Modal"));
|
||||
expect(screen.queryByTestId("action-detail-modal")).not.toBeInTheDocument();
|
||||
|
||||
// Click the second action button
|
||||
const actionButton2 = screen.getByTitle("Action 2");
|
||||
await userEvent.click(actionButton2);
|
||||
|
||||
// Modal should open for the second action
|
||||
const modal2 = screen.getByTestId("action-detail-modal");
|
||||
expect(modal2).toBeInTheDocument();
|
||||
expect(modal2).toHaveTextContent("Modal for Action 2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
import { ActionDetailModal } from "./ActionDetailModal";
|
||||
// Import mocked components
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
|
||||
<div data-testid="modal-with-tabs">
|
||||
<span data-testid="modal-label">{label}</span>
|
||||
<span data-testid="modal-description">{description}</span>
|
||||
<span data-testid="modal-open">{open.toString()}</span>
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
{icon}
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
{tab.children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionActivityTab", () => ({
|
||||
ActionActivityTab: vi.fn(() => <div data-testid="action-activity-tab">ActionActivityTab</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionSettingsTab", () => ({
|
||||
ActionSettingsTab: vi.fn(() => <div data-testid="action-settings-tab">ActionSettingsTab</div>),
|
||||
}));
|
||||
|
||||
// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
||||
ACTION_TYPE_ICON_LOOKUP: {
|
||||
code: <div data-testid="code-icon">Code Icon Mock</div>,
|
||||
noCode: <div data-testid="nocode-icon">No Code Icon Mock</div>,
|
||||
// Add other types if needed by other tests or default props
|
||||
},
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production", // Use string literal as TEnvironmentType is not exported
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockActionClass: TActionClass = {
|
||||
id: "action-class-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action",
|
||||
description: "This is a test action",
|
||||
type: "code", // Ensure this matches a key in the mocked ACTION_TYPE_ICON_LOOKUP
|
||||
environmentId: mockEnvironmentId,
|
||||
noCodeConfig: null,
|
||||
key: "test-action-key",
|
||||
};
|
||||
|
||||
const mockActionClasses: TActionClass[] = [mockActionClass];
|
||||
const mockOtherEnvActionClasses: TActionClass[] = [];
|
||||
const mockOtherEnvironment = { ...mockEnvironment, id: "other-env-id", name: "Other Environment" };
|
||||
|
||||
const defaultProps = {
|
||||
environmentId: mockEnvironmentId,
|
||||
environment: mockEnvironment,
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
actionClass: mockActionClass,
|
||||
actionClasses: mockActionClasses,
|
||||
isReadOnly: false,
|
||||
otherEnvironment: mockOtherEnvironment,
|
||||
otherEnvActionClasses: mockOtherEnvActionClasses,
|
||||
};
|
||||
|
||||
describe("ActionDetailModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
test("renders ModalWithTabs with correct props", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
|
||||
expect(mockedModalWithTabs).toHaveBeenCalled();
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
// Check basic props
|
||||
expect(props.open).toBe(true);
|
||||
expect(props.setOpen).toBe(mockSetOpen);
|
||||
expect(props.label).toBe(mockActionClass.name);
|
||||
expect(props.description).toBe(mockActionClass.description);
|
||||
|
||||
// Check icon data-testid based on the mock for the default 'code' type
|
||||
expect(props.icon).toBeDefined();
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
|
||||
|
||||
// Check tabs structure
|
||||
expect(props.tabs).toHaveLength(2);
|
||||
expect(props.tabs[0].title).toBe("common.activity");
|
||||
expect(props.tabs[1].title).toBe("common.settings");
|
||||
|
||||
// Check if the correct mocked components are used as children
|
||||
// Access the mocked functions directly
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
||||
|
||||
// Check props passed to child components
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
|
||||
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
|
||||
expect(activityTabProps.isReadOnly).toBe(false);
|
||||
expect(activityTabProps.environment).toBe(mockEnvironment);
|
||||
expect(activityTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
|
||||
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
|
||||
expect(settingsTabProps.isReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
test("renders correct icon based on action type", () => {
|
||||
// Test with 'noCode' type
|
||||
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
||||
expect(props.icon).toBeDefined();
|
||||
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
|
||||
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
|
||||
});
|
||||
|
||||
test("passes isReadOnly prop correctly", () => {
|
||||
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
|
||||
// Access the mocked component directly
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.isReadOnly).toBe(true);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.isReadOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { ActionClassDataRow } from "./ActionRowData";
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockActionClass: TActionClass = {
|
||||
id: "testId",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action",
|
||||
description: "This is a test action",
|
||||
type: "code",
|
||||
noCodeConfig: null,
|
||||
environmentId: "envId",
|
||||
key: null,
|
||||
};
|
||||
|
||||
const locale = "en-US";
|
||||
const timeSinceOutput = "2 hours ago";
|
||||
|
||||
describe("ActionClassDataRow", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders code action correctly", () => {
|
||||
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
|
||||
const actionClass = { ...mockActionClass, type: "code" } as TActionClass;
|
||||
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
|
||||
|
||||
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
|
||||
expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
|
||||
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
|
||||
expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
|
||||
});
|
||||
|
||||
test("renders no-code action correctly", () => {
|
||||
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
|
||||
const actionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
|
||||
|
||||
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
|
||||
expect(screen.getByText(actionClass.description!)).toBeInTheDocument();
|
||||
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
|
||||
expect(timeSince).toHaveBeenCalledWith(actionClass.createdAt.toString(), locale);
|
||||
});
|
||||
|
||||
test("renders without description", () => {
|
||||
vi.mocked(timeSince).mockReturnValue(timeSinceOutput);
|
||||
const actionClass = { ...mockActionClass, description: undefined } as unknown as TActionClass;
|
||||
render(<ActionClassDataRow actionClass={actionClass} locale={locale} />);
|
||||
|
||||
expect(screen.getByText(actionClass.name)).toBeInTheDocument();
|
||||
expect(screen.queryByText("This is a test action")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(timeSinceOutput)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass, TActionClassNoCodeConfig, TActionClassType } from "@formbricks/types/action-classes";
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// Mock actions
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
deleteActionClassAction: vi.fn(),
|
||||
updateActionClassAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
||||
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, loading, ...props }: any) => (
|
||||
<button onClick={onClick} data-variant={variant} disabled={loading} {...props}>
|
||||
{loading ? "Loading..." : children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
||||
Code Action Form
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<span>Delete Dialog</span>
|
||||
<button onClick={onDelete} disabled={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Confirm Delete"}
|
||||
</button>
|
||||
<button onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
||||
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
||||
No Code Action Form
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
||||
}));
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{
|
||||
id: "action1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Existing Action",
|
||||
description: "An existing action",
|
||||
type: "noCode",
|
||||
environmentId: "env1",
|
||||
noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
|
||||
} as unknown as TActionClass,
|
||||
];
|
||||
|
||||
const createMockActionClass = (id: string, type: TActionClassType, name: string): TActionClass =>
|
||||
({
|
||||
id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name,
|
||||
description: `${name} description`,
|
||||
type,
|
||||
environmentId: "env1",
|
||||
...(type === "code" && { key: `${name}-key` }),
|
||||
...(type === "noCode" && {
|
||||
noCodeConfig: { type: "url", rule: "exactMatch", value: `http://${name}.com` },
|
||||
}),
|
||||
}) as unknown as TActionClass;
|
||||
|
||||
describe("ActionSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders correctly for 'code' action type", () => {
|
||||
const actionClass = createMockActionClass("code1", "code", "Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||
actionClass.name
|
||||
);
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||
actionClass.description
|
||||
);
|
||||
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly for 'noCode' action type", () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||
actionClass.name
|
||||
);
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||
actionClass.description
|
||||
);
|
||||
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles successful deletion", async () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
const { deleteActionClassAction } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
vi.mocked(deleteActionClassAction).mockResolvedValue({ data: actionClass } as any);
|
||||
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
|
||||
await userEvent.click(deleteButtonTrigger);
|
||||
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
|
||||
const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteActionClassAction).toHaveBeenCalledWith({ actionClassId: actionClass.id });
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.actions.action_deleted_successfully");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles deletion failure", async () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
const { deleteActionClassAction } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
vi.mocked(deleteActionClassAction).mockRejectedValue(new Error("Deletion failed"));
|
||||
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButtonTrigger = screen.getByRole("button", { name: /common.delete/ });
|
||||
await userEvent.click(deleteButtonTrigger);
|
||||
const confirmDeleteButton = screen.getByRole("button", { name: "Confirm Delete" });
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteActionClassAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
|
||||
});
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders read-only state correctly", () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={true} // Set to read-only
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
|
||||
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
|
||||
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
|
||||
});
|
||||
|
||||
test("prevents delete when read-only", async () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
const { deleteActionClassAction } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
|
||||
// Render with isReadOnly=true, but simulate a delete attempt
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
|
||||
// This test primarily checks the logic within handleDeleteAction if it were called.
|
||||
// A better approach might be to export handleDeleteAction for direct testing,
|
||||
// but for now, we assume the UI prevents calling it.
|
||||
|
||||
// We can assert that the delete button isn't there to prevent the flow
|
||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||
expect(deleteActionClassAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders docs link correctly", () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
const docsLink = screen.getByRole("link", { name: "common.read_docs" });
|
||||
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ActionTableHeading } from "./ActionTableHeading";
|
||||
|
||||
// Mock the server-side translation function
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("ActionTableHeading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the table heading with correct column names", async () => {
|
||||
// Render the async component
|
||||
const ResolvedComponent = await ActionTableHeading();
|
||||
render(ResolvedComponent);
|
||||
|
||||
// Check if the translated column headers are present
|
||||
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.created")).toBeInTheDocument();
|
||||
// Check for the screen reader only text
|
||||
expect(screen.getByText("common.edit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
|
||||
import { AddActionModal } from "./AddActionModal";
|
||||
|
||||
// Mock child components and hooks
|
||||
vi.mock("@/modules/survey/editor/components/create-new-action-tab", () => ({
|
||||
CreateNewActionTab: vi.fn(({ setOpen }) => (
|
||||
<div data-testid="create-new-action-tab">
|
||||
<span>CreateNewActionTab Content</span>
|
||||
<button onClick={() => setOpen(false)}>Close from Tab</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" {...props}>
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
MousePointerClickIcon: () => <div data-testid="mouse-pointer-icon" />,
|
||||
PlusIcon: () => <div data-testid="plus-icon" />,
|
||||
}));
|
||||
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{
|
||||
id: "action1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Action 1",
|
||||
description: "Description 1",
|
||||
type: "noCode",
|
||||
environmentId: "env1",
|
||||
noCodeConfig: { type: "click" } as unknown as TActionClassNoCodeConfig,
|
||||
} as unknown as TActionClass,
|
||||
];
|
||||
|
||||
const environmentId = "env1";
|
||||
|
||||
describe("AddActionModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the 'Add Action' button initially", () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the modal when the 'Add Action' button is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes correct props to CreateNewActionTab", async () => {
|
||||
const { CreateNewActionTab } = await import("@/modules/survey/editor/components/create-new-action-tab");
|
||||
const mockedCreateNewActionTab = vi.mocked(CreateNewActionTab);
|
||||
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(mockedCreateNewActionTab).toHaveBeenCalled();
|
||||
const props = mockedCreateNewActionTab.mock.calls[0][0];
|
||||
expect(props.environmentId).toBe(environmentId);
|
||||
expect(props.actionClasses).toEqual(mockActionClasses); // Initial state check
|
||||
expect(props.isReadOnly).toBe(false);
|
||||
expect(props.setOpen).toBeInstanceOf(Function);
|
||||
expect(props.setActionClasses).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("closes the modal when the close button (simulated) is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked Modal's close button
|
||||
const closeModalButton = screen.getByText("Close Modal");
|
||||
await userEvent.click(closeModalButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked CreateNewActionTab's button
|
||||
const closeFromTabButton = screen.getByText("Close from Tab");
|
||||
await userEvent.click(closeFromTabButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Loading from "./loading";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="page-content-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
|
||||
}));
|
||||
|
||||
describe("Loading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading state correctly", () => {
|
||||
render(<Loading />);
|
||||
|
||||
// Check if mocked components are rendered
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions");
|
||||
|
||||
// Check for translated table headers
|
||||
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.created")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text
|
||||
|
||||
// Check for skeleton elements (presence of animate-pulse class)
|
||||
const skeletonElements = document.querySelectorAll(".animate-pulse");
|
||||
expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered
|
||||
|
||||
// Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12)
|
||||
const pulseDivs = screen.getAllByText((_, element) => {
|
||||
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
|
||||
});
|
||||
expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created)
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ const Loading = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
// Import the component after mocks
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/actionClass/service", () => ({
|
||||
getActionClasses: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironments: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({
|
||||
ActionClassesTable: ({ children }) => <div>ActionClassesTable Mock{children}</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
|
||||
ActionClassDataRow: ({ actionClass }) => <div>ActionClassDataRow Mock: {actionClass.name}</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
|
||||
ActionTableHeading: () => <div>ActionTableHeading Mock</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
|
||||
AddActionModal: () => <div>AddActionModal Mock</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: ({ children }) => <div>PageContentWrapper Mock{children}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: ({ pageTitle, cta }) => (
|
||||
<div>
|
||||
PageHeader Mock: {pageTitle} {cta && <div>CTA Mock</div>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockProjectId = "test-project-id";
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
name: "Test Environment",
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockOtherEnvironment = {
|
||||
id: "other-env-id",
|
||||
name: "Other Environment",
|
||||
type: "production",
|
||||
} as unknown as TEnvironment;
|
||||
const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject;
|
||||
const mockActionClasses = [
|
||||
{ id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass,
|
||||
{ id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass,
|
||||
];
|
||||
const mockOtherEnvActionClasses = [
|
||||
{ id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass,
|
||||
];
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const mockParams = { environmentId: mockEnvironmentId };
|
||||
const mockProps = { params: mockParams };
|
||||
|
||||
describe("Actions Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getActionClasses)
|
||||
.mockResolvedValueOnce(mockActionClasses) // First call for current env
|
||||
.mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env
|
||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("renders the page correctly with actions", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument();
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects if isBilling is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: true,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
await Page(mockProps);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
|
||||
});
|
||||
|
||||
test("does not render AddActionModal CTA if isReadOnly is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: true,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders AddActionModal CTA if isReadOnly is false", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { Code2Icon, MousePointerClickIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ACTION_TYPE_ICON_LOOKUP } from "./utils";
|
||||
|
||||
describe("ACTION_TYPE_ICON_LOOKUP", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should contain the correct icon for 'code'", () => {
|
||||
expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("code");
|
||||
const IconComponent = ACTION_TYPE_ICON_LOOKUP.code;
|
||||
expect(React.isValidElement(IconComponent)).toBe(true);
|
||||
|
||||
// Render the icon and check if it's the correct Lucide icon
|
||||
const { container } = render(IconComponent);
|
||||
const svgElement = container.querySelector("svg");
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
// Check for a class or attribute specific to Code2Icon if possible,
|
||||
// or compare the rendered output structure if necessary.
|
||||
// For simplicity, we check the component type directly (though this is less robust)
|
||||
expect(IconComponent.type).toBe(Code2Icon);
|
||||
});
|
||||
|
||||
test("should contain the correct icon for 'noCode'", () => {
|
||||
expect(ACTION_TYPE_ICON_LOOKUP).toHaveProperty("noCode");
|
||||
const IconComponent = ACTION_TYPE_ICON_LOOKUP.noCode;
|
||||
expect(React.isValidElement(IconComponent)).toBe(true);
|
||||
|
||||
// Render the icon and check if it's the correct Lucide icon
|
||||
const { container } = render(IconComponent);
|
||||
const svgElement = container.querySelector("svg");
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
// Similar check as above for MousePointerClickIcon
|
||||
expect(IconComponent.type).toBe(MousePointerClickIcon);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import {
|
||||
TOrganization,
|
||||
TOrganizationBilling,
|
||||
TOrganizationBillingPlanLimits,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
getEnvironments: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
getOrganizationsByUserId: vi.fn(),
|
||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getUserProjects: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
getProjectPermissionByUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
let mockIsDevelopment = false;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockIsFormbricksCloud;
|
||||
},
|
||||
get IS_DEVELOPMENT() {
|
||||
return mockIsDevelopment;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
||||
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
|
||||
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
||||
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/pending-downgrade-banner", () => ({
|
||||
PendingDowngradeBanner: ({
|
||||
isPendingDowngrade,
|
||||
active,
|
||||
}: {
|
||||
isPendingDowngrade: boolean;
|
||||
active: boolean;
|
||||
}) =>
|
||||
isPendingDowngrade && active ? <div data-testid="downgrade-banner">PendingDowngradeBanner</div> : null,
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockOrganization = {
|
||||
id: "org-1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
|
||||
} as unknown as TOrganizationBilling,
|
||||
} as unknown as TOrganization;
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockProject: TProject = {
|
||||
id: "proj-1",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "org-1",
|
||||
environments: [mockEnvironment],
|
||||
} as unknown as TProject;
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "org-1",
|
||||
userId: "user-1",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
|
||||
const mockLicense = {
|
||||
plan: "free",
|
||||
active: false,
|
||||
lastChecked: new Date(),
|
||||
features: { isMultiOrgEnabled: false },
|
||||
} as any;
|
||||
|
||||
const mockProjectPermission = {
|
||||
userId: "user-1",
|
||||
projectId: "proj-1",
|
||||
role: "admin",
|
||||
} as any;
|
||||
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-1",
|
||||
},
|
||||
expires: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
describe("EnvironmentLayout", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
mockIsDevelopment = false;
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly with default props", async () => {
|
||||
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...mockLicense,
|
||||
isPendingDowngrade: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
});
|
||||
|
||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
||||
// Ensure the license mock reflects the condition needed for the banner
|
||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
|
||||
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.user_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"environments.projects_environments_organizations_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if member has no project permission", async () => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.project_permission_not_found"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import EnvironmentStorageHandler from "./EnvironmentStorageHandler";
|
||||
|
||||
describe("EnvironmentStorageHandler", () => {
|
||||
test("sets environmentId in localStorage on mount", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const testEnvironmentId = "test-env-123";
|
||||
|
||||
render(<EnvironmentStorageHandler environmentId={testEnvironmentId} />);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, testEnvironmentId);
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("updates environmentId in localStorage when prop changes", () => {
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const initialEnvironmentId = "test-env-initial";
|
||||
const updatedEnvironmentId = "test-env-updated";
|
||||
|
||||
const { rerender } = render(<EnvironmentStorageHandler environmentId={initialEnvironmentId} />);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, initialEnvironmentId);
|
||||
|
||||
rerender(<EnvironmentStorageHandler environmentId={updatedEnvironmentId} />);
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, updatedEnvironmentId);
|
||||
expect(setItemSpy).toHaveBeenCalledTimes(2); // Called on mount and on rerender with new prop
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { EnvironmentSwitch } from "./EnvironmentSwitch";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: mockPush,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock @tolgee/react
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentDev: TEnvironment = {
|
||||
id: "dev-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironmentProd: TEnvironment = {
|
||||
id: "prod-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
|
||||
|
||||
describe("EnvironmentSwitch", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders checked when environment is development", () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
expect(switchElement).toBeChecked();
|
||||
expect(screen.getByText("common.dev_env")).toHaveClass("text-orange-800");
|
||||
});
|
||||
|
||||
test("renders unchecked when environment is production", () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
expect(switchElement).not.toBeChecked();
|
||||
expect(screen.getByText("common.dev_env")).not.toHaveClass("text-orange-800");
|
||||
});
|
||||
|
||||
test("calls router.push with development environment ID when toggled from production", async () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
|
||||
expect(switchElement).not.toBeChecked();
|
||||
await userEvent.click(switchElement);
|
||||
|
||||
// Check loading state (switch disabled)
|
||||
expect(switchElement).toBeDisabled();
|
||||
|
||||
// Check router push call
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
|
||||
});
|
||||
|
||||
// Check visual state change (though state update happens before navigation)
|
||||
// In a real scenario, the component would re-render with the new environment prop after navigation.
|
||||
// Here, we simulate the state change directly for testing the toggle logic.
|
||||
await waitFor(() => {
|
||||
// Re-render or check internal state if possible, otherwise check mock calls
|
||||
// Since the component manages its own state, we can check the visual state after click
|
||||
expect(switchElement).toBeChecked(); // State updates immediately
|
||||
});
|
||||
});
|
||||
|
||||
test("calls router.push with production environment ID when toggled from development", async () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
|
||||
expect(switchElement).toBeChecked();
|
||||
await userEvent.click(switchElement);
|
||||
|
||||
// Check loading state (switch disabled)
|
||||
expect(switchElement).toBeDisabled();
|
||||
|
||||
// Check router push call
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentProd.id}/`);
|
||||
});
|
||||
|
||||
// Check visual state change
|
||||
await waitFor(() => {
|
||||
expect(switchElement).not.toBeChecked(); // State updates immediately
|
||||
});
|
||||
});
|
||||
|
||||
test("does not call router.push if target environment is not found", async () => {
|
||||
const incompleteEnvironments = [mockEnvironmentProd]; // Only production exists
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={incompleteEnvironments} />);
|
||||
const switchElement = screen.getByRole("switch");
|
||||
|
||||
await userEvent.click(switchElement); // Try to toggle to development
|
||||
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeDisabled(); // Loading state still set
|
||||
});
|
||||
|
||||
// router.push should not be called because dev env is missing
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
|
||||
// State still updates visually
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("toggles using the label click", async () => {
|
||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
||||
const labelElement = screen.getByText("common.dev_env");
|
||||
const switchElement = screen.getByRole("switch");
|
||||
|
||||
expect(switchElement).not.toBeChecked();
|
||||
await userEvent.click(labelElement); // Click the label
|
||||
|
||||
// Check loading state (switch disabled)
|
||||
expect(switchElement).toBeDisabled();
|
||||
|
||||
// Check router push call
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
|
||||
});
|
||||
|
||||
// Check visual state change
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { getLatestStableFbReleaseAction } from "../actions/actions";
|
||||
import { MainNavigation } from "./MainNavigation";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
usePathname: vi.fn(() => "/environments/env1/surveys"),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
getLatestStableFbReleaseAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/lib/formbricks", () => ({
|
||||
formbricksLogout: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: (role?: string) => ({
|
||||
isAdmin: role === "admin",
|
||||
isOwner: role === "owner",
|
||||
isManager: role === "manager",
|
||||
isMember: role === "member",
|
||||
isBilling: role === "billing",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
||||
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
|
||||
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||
Project Switcher
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: (props: any) => <img alt="test" {...props} />,
|
||||
}));
|
||||
vi.mock("../../../../../package.json", () => ({
|
||||
version: "1.0.0",
|
||||
}));
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
|
||||
// Mock data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
emailVerified: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockOrganization = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: { stripeCustomerId: null, plan: "free", limits: { monthly: { responses: null } } } as any,
|
||||
} as unknown as TOrganization;
|
||||
|
||||
const mockOrganizations: TOrganization[] = [
|
||||
mockOrganization,
|
||||
{ ...mockOrganization, id: "org2", name: "Another Org" },
|
||||
];
|
||||
const mockProject: TProject = {
|
||||
id: "proj1",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "org1",
|
||||
environments: [mockEnvironment],
|
||||
config: { channel: "website" },
|
||||
} as unknown as TProject;
|
||||
const mockProjects: TProject[] = [mockProject];
|
||||
|
||||
const defaultProps = {
|
||||
environment: mockEnvironment,
|
||||
organizations: mockOrganizations,
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
projects: mockProjects,
|
||||
isMultiOrgEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
isDevelopment: false,
|
||||
membershipRole: "owner" as const,
|
||||
organizationProjectsLimit: 5,
|
||||
isLicenseActive: true,
|
||||
};
|
||||
|
||||
describe("MainNavigation", () => {
|
||||
let mockRouterPush: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRouterPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any);
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env1/surveys");
|
||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null }); // Default: no new version
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders expanded by default and collapses on toggle", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
const projectSwitcher = screen.getByTestId("project-switcher");
|
||||
// Assuming the toggle button is the only one initially without an accessible name
|
||||
// A more specific selector like data-testid would be better if available.
|
||||
const toggleButton = screen.getByRole("button", { name: "" });
|
||||
|
||||
// Check initial state (expanded)
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Check localStorage is not set initially after clear()
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
||||
|
||||
// Click to collapse
|
||||
await userEvent.click(toggleButton);
|
||||
|
||||
// Check state after first toggle (collapsed)
|
||||
await waitFor(() => {
|
||||
// Check that the attribute eventually becomes true
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
|
||||
// Check that localStorage is updated
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
||||
});
|
||||
// Check that the logo is eventually hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByAltText("environments.formbricks_logo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand
|
||||
await userEvent.click(toggleButton);
|
||||
|
||||
// Check state after second toggle (expanded)
|
||||
await waitFor(() => {
|
||||
// Check that the attribute eventually becomes false
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||
// Check that localStorage is updated
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
||||
});
|
||||
// Check that the logo is eventually visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders correct active navigation link", () => {
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env1/actions");
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
const actionsLink = screen.getByRole("link", { name: /common.actions/ });
|
||||
// Check if the parent li has the active class styling
|
||||
expect(actionsLink.closest("li")).toHaveClass("border-brand-dark");
|
||||
});
|
||||
|
||||
test("renders user dropdown and handles logout", async () => {
|
||||
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Find the avatar and get its parent div which acts as the trigger
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the dropdown content to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.account")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("common.organization")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
|
||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
||||
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
|
||||
await userEvent.click(org2Item);
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
|
||||
});
|
||||
|
||||
test("opens create organization modal", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
|
||||
await userEvent.click(createOrgButton);
|
||||
|
||||
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides new version banner for members or if no new version", async () => {
|
||||
// Test for member
|
||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
||||
render(<MainNavigation {...defaultProps} membershipRole="member" />);
|
||||
let toggleButton = screen.getByRole("button", { name: "" });
|
||||
await userEvent.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
cleanup(); // Clean up before next render
|
||||
|
||||
// Test for no new version
|
||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null });
|
||||
render(<MainNavigation {...defaultProps} membershipRole="owner" />);
|
||||
toggleButton = screen.getByRole("button", { name: "" });
|
||||
await userEvent.click(toggleButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("hides main nav and project switcher if user role is billing", () => {
|
||||
render(<MainNavigation {...defaultProps} membershipRole="billing" />);
|
||||
expect(screen.queryByRole("link", { name: /common.surveys/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows billing link and hides license link in cloud", async () => {
|
||||
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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")}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { NavbarLoading } from "./NavbarLoading";
|
||||
|
||||
describe("NavbarLoading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the correct number of skeleton elements", () => {
|
||||
render(<NavbarLoading />);
|
||||
|
||||
// Find all divs with the animate-pulse class
|
||||
const skeletonElements = screen.getAllByText((content, element) => {
|
||||
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
|
||||
});
|
||||
|
||||
// There are 8 skeleton divs in the component
|
||||
expect(skeletonElements).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { NavigationLink } from "./NavigationLink";
|
||||
|
||||
// Mock next/link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
// Mock tooltip components
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
),
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-provider">{children}</div>
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-trigger">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
href: "/test-link",
|
||||
isActive: false,
|
||||
isCollapsed: false,
|
||||
children: <svg data-testid="icon" />,
|
||||
linkText: "Test Link Text",
|
||||
isTextVisible: true,
|
||||
};
|
||||
|
||||
describe("NavigationLink", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders expanded link correctly (inactive, text visible)", () => {
|
||||
render(<NavigationLink {...defaultProps} />);
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
const textSpan = screen.getByText(defaultProps.linkText);
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveClass("opacity-0");
|
||||
expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
|
||||
expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
|
||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders expanded link correctly (active, text hidden)", () => {
|
||||
render(<NavigationLink {...defaultProps} isActive={true} isTextVisible={false} />);
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
const textSpan = screen.getByText(defaultProps.linkText);
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
expect(textSpan).toBeInTheDocument();
|
||||
expect(textSpan).toHaveClass("opacity-100");
|
||||
expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
|
||||
expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
|
||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders collapsed link correctly (inactive)", () => {
|
||||
render(<NavigationLink {...defaultProps} isCollapsed={true} />);
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
// Check text is NOT directly within the list item
|
||||
expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
|
||||
expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
|
||||
expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
|
||||
|
||||
// Check tooltip elements
|
||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
|
||||
// Check text IS within the tooltip content mock
|
||||
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
|
||||
});
|
||||
|
||||
test("renders collapsed link correctly (active)", () => {
|
||||
render(<NavigationLink {...defaultProps} isCollapsed={true} isActive={true} />);
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
// Check text is NOT directly within the list item
|
||||
expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
|
||||
expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
|
||||
expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
|
||||
|
||||
// Check tooltip elements
|
||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
||||
// Check text IS within the tooltip content mock
|
||||
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ProjectNavItem } from "./ProjectNavItem";
|
||||
|
||||
describe("ProjectNavItem", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
href: "/test-path",
|
||||
children: <span>Test Child</span>,
|
||||
};
|
||||
|
||||
test("renders correctly when active", () => {
|
||||
render(<ProjectNavItem {...defaultProps} isActive={true} />);
|
||||
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", "/test-path");
|
||||
expect(screen.getByText("Test Child")).toBeInTheDocument();
|
||||
expect(listItem).toHaveClass("bg-slate-50");
|
||||
expect(listItem).toHaveClass("font-semibold");
|
||||
expect(listItem).not.toHaveClass("hover:bg-slate-50");
|
||||
});
|
||||
|
||||
test("renders correctly when inactive", () => {
|
||||
render(<ProjectNavItem {...defaultProps} isActive={false} />);
|
||||
|
||||
const linkElement = screen.getByRole("link");
|
||||
const listItem = linkElement.closest("li");
|
||||
|
||||
expect(linkElement).toHaveAttribute("href", "/test-path");
|
||||
expect(screen.getByText("Test Child")).toBeInTheDocument();
|
||||
expect(listItem).not.toHaveClass("bg-slate-50");
|
||||
expect(listItem).not.toHaveClass("font-semibold");
|
||||
expect(listItem).toHaveClass("hover:bg-slate-50");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { QuestionOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ResponseFilterProvider, useResponseFilter } from "./ResponseFilterContext";
|
||||
|
||||
// Mock the getTodayDate function
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
getTodayDate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockToday = new Date("2024-01-15T00:00:00.000Z");
|
||||
const mockFromDate = new Date("2024-01-01T00:00:00.000Z");
|
||||
|
||||
// Test component to use the hook
|
||||
const TestComponent = () => {
|
||||
const {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
} = useResponseFilter();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
|
||||
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
||||
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
||||
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
||||
<div data-testid="dateFrom">{dateRange.from?.toISOString()}</div>
|
||||
<div data-testid="dateTo">{dateRange.to?.toISOString()}</div>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelectedFilter({
|
||||
filter: [
|
||||
{
|
||||
questionType: { id: "q1", label: "Question 1" },
|
||||
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
||||
},
|
||||
],
|
||||
onlyComplete: true,
|
||||
})
|
||||
}>
|
||||
Update Filter
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelectedOptions({
|
||||
questionOptions: [{ header: "q1" } as unknown as QuestionOptions],
|
||||
questionFilterOptions: [{ id: "qFilterOpt1" } as unknown as QuestionFilterOptions],
|
||||
})
|
||||
}>
|
||||
Update Options
|
||||
</button>
|
||||
<button onClick={() => setDateRange({ from: mockFromDate, to: mockToday })}>Update Date Range</button>
|
||||
<button onClick={resetState}>Reset State</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("ResponseFilterContext", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTodayDate).mockReturnValue(mockToday);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should provide initial state values", () => {
|
||||
render(
|
||||
<ResponseFilterProvider>
|
||||
<TestComponent />
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
|
||||
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("dateFrom").textContent).toBe("");
|
||||
expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
|
||||
});
|
||||
|
||||
test("should update selectedFilter state", async () => {
|
||||
render(
|
||||
<ResponseFilterProvider>
|
||||
<TestComponent />
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
const updateButton = screen.getByText("Update Filter");
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
|
||||
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
||||
});
|
||||
|
||||
test("should update selectedOptions state", async () => {
|
||||
render(
|
||||
<ResponseFilterProvider>
|
||||
<TestComponent />
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
const updateButton = screen.getByText("Update Options");
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("1");
|
||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("1");
|
||||
});
|
||||
|
||||
test("should update dateRange state", async () => {
|
||||
render(
|
||||
<ResponseFilterProvider>
|
||||
<TestComponent />
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
const updateButton = screen.getByText("Update Date Range");
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
expect(screen.getByTestId("dateFrom").textContent).toBe(mockFromDate.toISOString());
|
||||
expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
|
||||
});
|
||||
|
||||
test("should throw error when useResponseFilter is used outside of Provider", () => {
|
||||
// Hide console error temporarily
|
||||
const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => render(<TestComponent />)).toThrow("useFilterDate must be used within a FilterDateProvider");
|
||||
consoleErrorMock.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TopControlBar } from "./TopControlBar";
|
||||
|
||||
// Mock the child component
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
|
||||
TopControlButtons: vi.fn(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
|
||||
}));
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments: TEnvironment[] = [
|
||||
mockEnvironment,
|
||||
{ ...mockEnvironment, id: "env2", type: "development" },
|
||||
];
|
||||
|
||||
const mockMembershipRole: TOrganizationRole = "owner";
|
||||
const mockProjectPermission = "manage";
|
||||
|
||||
describe("TopControlBar", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly and passes props to TopControlButtons", () => {
|
||||
render(
|
||||
<TopControlBar
|
||||
environment={mockEnvironment}
|
||||
environments={mockEnvironments}
|
||||
membershipRole={mockMembershipRole}
|
||||
projectPermission={mockProjectPermission}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the main div is rendered
|
||||
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
|
||||
expect(mainDiv).toHaveClass(
|
||||
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
|
||||
);
|
||||
|
||||
// Check if the mocked child component is rendered
|
||||
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
|
||||
|
||||
// Check if the child component received the correct props
|
||||
expect(TopControlButtons).toHaveBeenCalledWith(
|
||||
{
|
||||
environment: mockEnvironment,
|
||||
environments: mockEnvironments,
|
||||
membershipRole: mockMembershipRole,
|
||||
projectPermission: mockProjectPermission,
|
||||
},
|
||||
undefined // Updated from {} to undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TopControlButtons } from "./TopControlButtons";
|
||||
|
||||
// Mock dependencies
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: mockPush })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/teams/utils/teams", () => ({
|
||||
getTeamPermissionFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({
|
||||
EnvironmentSwitch: vi.fn(() => <div data-testid="environment-switch">EnvironmentSwitch</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => {
|
||||
const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock
|
||||
return (
|
||||
<Tag onClick={onClick} data-testid={`button-${className}`} {...props}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
|
||||
<div data-testid={`tooltip-${tooltipContent.split(".").pop()}`}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
BugIcon: () => <div data-testid="bug-icon" />,
|
||||
CircleUserIcon: () => <div data-testid="circle-user-icon" />,
|
||||
PlusIcon: () => <div data-testid="plus-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
|
||||
<a href={href} target={target} data-testid="link-mock">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironmentDev: TEnvironment = {
|
||||
id: "dev-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironmentProd: TEnvironment = {
|
||||
id: "prod-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
|
||||
|
||||
describe("TopControlButtons", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mocks for access flags
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
isBilling: false,
|
||||
} as any);
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const renderComponent = (
|
||||
membershipRole?: TOrganizationRole,
|
||||
projectPermission: any = null,
|
||||
isBilling = false,
|
||||
hasReadAccess = false
|
||||
) => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isMember: membershipRole === "member",
|
||||
isBilling: isBilling,
|
||||
isOwner: membershipRole === "owner",
|
||||
} as any);
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: hasReadAccess,
|
||||
} as any);
|
||||
|
||||
return render(
|
||||
<TopControlButtons
|
||||
environment={mockEnvironmentDev}
|
||||
environments={mockEnvironments}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test("renders correctly for Owner role", async () => {
|
||||
renderComponent("owner");
|
||||
|
||||
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bug-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
|
||||
// Check link
|
||||
const link = screen.getByTestId("link-mock");
|
||||
expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
|
||||
// Click account button
|
||||
const accountButton = screen.getByTestId("circle-user-icon").closest("button");
|
||||
await userEvent.click(accountButton!);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`);
|
||||
});
|
||||
|
||||
// Click new survey button
|
||||
const newSurveyButton = screen.getByTestId("plus-icon").closest("button");
|
||||
await userEvent.click(newSurveyButton!);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`);
|
||||
});
|
||||
});
|
||||
|
||||
test("hides EnvironmentSwitch for Billing role", () => {
|
||||
renderComponent(undefined, null, true); // isBilling = true
|
||||
expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing
|
||||
});
|
||||
|
||||
test("hides New Survey button for Billing role", () => {
|
||||
renderComponent(undefined, null, true); // isBilling = true
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides New Survey button for read-only Member", () => {
|
||||
renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true
|
||||
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows New Survey button for Member with write access", () => {
|
||||
renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false
|
||||
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { WidgetStatusIndicator } from "./WidgetStatusIndicator";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: mockRefresh,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
AlertTriangleIcon: () => <div data-testid="alert-icon">AlertTriangleIcon</div>,
|
||||
CheckIcon: () => <div data-testid="check-icon">CheckIcon</div>,
|
||||
RotateCcwIcon: () => <div data-testid="refresh-icon">RotateCcwIcon</div>,
|
||||
}));
|
||||
|
||||
// Mock Button component
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockEnvironmentNotImplemented: TEnvironment = {
|
||||
id: "env-not-implemented",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: false, // Not implemented state
|
||||
};
|
||||
|
||||
const mockEnvironmentRunning: TEnvironment = {
|
||||
id: "env-running",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: true, // Running state
|
||||
};
|
||||
|
||||
describe("WidgetStatusIndicator", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly for 'notImplemented' state", () => {
|
||||
render(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
|
||||
|
||||
// Check icon
|
||||
expect(screen.getByTestId("alert-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
|
||||
|
||||
// Check texts
|
||||
expect(
|
||||
screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected_description")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check button
|
||||
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
|
||||
expect(recheckButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId("refresh-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly for 'running' state", () => {
|
||||
render(<WidgetStatusIndicator environment={mockEnvironmentRunning} />);
|
||||
|
||||
// Check icon
|
||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("alert-icon")).not.toBeInTheDocument();
|
||||
|
||||
// Check texts
|
||||
expect(screen.getByText("environments.project.app-connection.receiving_data")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.project.app-connection.formbricks_sdk_connected")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check button absence
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /environments.project.app-connection.recheck/ })
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("refresh-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls router.refresh when 'Recheck' button is clicked", async () => {
|
||||
render(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
|
||||
|
||||
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
|
||||
await userEvent.click(recheckButton);
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
}));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,450 @@
|
||||
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { writeData as airtableWriteData } from "@/lib/airtable/service";
|
||||
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { writeData as writeNotionData } from "@/lib/notion/service";
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { writeDataToSlack } from "@/lib/slack/service";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { truncateText } from "@/lib/utils/strings";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfig,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableCredential,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfig,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
TIntegrationGoogleSheetsCredential,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionCredential,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import {
|
||||
TIntegrationSlack,
|
||||
TIntegrationSlackConfigData,
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { handleIntegrations } from "./handleIntegrations";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/airtable/service");
|
||||
vi.mock("@/lib/googleSheet/service");
|
||||
vi.mock("@/lib/i18n/utils");
|
||||
vi.mock("@/lib/notion/service");
|
||||
vi.mock("@/lib/responses");
|
||||
vi.mock("@/lib/slack/service");
|
||||
vi.mock("@/lib/utils/datetime");
|
||||
vi.mock("@/lib/utils/recall");
|
||||
vi.mock("@/lib/utils/strings");
|
||||
vi.mock("@formbricks/logger");
|
||||
|
||||
// Mock data
|
||||
const surveyId = "survey1";
|
||||
const questionId1 = "q1";
|
||||
const questionId2 = "q2";
|
||||
const questionId3 = "q3_picture";
|
||||
const hiddenFieldId = "hidden1";
|
||||
const variableId = "var1";
|
||||
|
||||
const mockPipelineInput = {
|
||||
environmentId: "env1",
|
||||
surveyId: surveyId,
|
||||
response: {
|
||||
id: "response1",
|
||||
createdAt: new Date("2024-01-01T12:00:00Z"),
|
||||
updatedAt: new Date("2024-01-01T12:00:00Z"),
|
||||
finished: true,
|
||||
surveyId: surveyId,
|
||||
data: {
|
||||
[questionId1]: "Answer 1",
|
||||
[questionId2]: ["Choice 1", "Choice 2"],
|
||||
[questionId3]: ["picChoice1"],
|
||||
[hiddenFieldId]: "Hidden Value",
|
||||
},
|
||||
meta: {
|
||||
url: "http://example.com",
|
||||
source: "web",
|
||||
userAgent: {
|
||||
browser: "Chrome",
|
||||
os: "Mac OS",
|
||||
device: "Desktop",
|
||||
},
|
||||
country: "USA",
|
||||
action: "Action Name",
|
||||
} as TResponseMeta,
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
personId: "person1",
|
||||
notes: [],
|
||||
tags: [],
|
||||
variables: {
|
||||
[variableId]: "Variable Value",
|
||||
},
|
||||
ttc: {},
|
||||
} as unknown as TResponse,
|
||||
} as TPipelineInput;
|
||||
|
||||
const mockSurvey = {
|
||||
id: surveyId,
|
||||
name: "Test Survey",
|
||||
questions: [
|
||||
{
|
||||
id: questionId1,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1 {{recall:q2}}" },
|
||||
required: true,
|
||||
} as unknown as TSurveyOpenTextQuestion,
|
||||
{
|
||||
id: questionId2,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "Question 2" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "choice1", label: { default: "Choice 1" } },
|
||||
{ id: "choice2", label: { default: "Choice 2" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: questionId3,
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "Question 3" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: "picChoice1", imageUrl: "http://image.com/1" },
|
||||
{ id: "picChoice2", imageUrl: "http://image.com/2" },
|
||||
],
|
||||
} as unknown as TSurveyPictureSelectionQuestion,
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: [hiddenFieldId],
|
||||
},
|
||||
variables: [{ id: variableId, name: "Variable 1" } as unknown as TSurvey["variables"][0]],
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
status: "inProgress",
|
||||
type: "app",
|
||||
languages: [],
|
||||
styling: {},
|
||||
segment: null,
|
||||
recontactDays: null,
|
||||
autoComplete: null,
|
||||
closeOnDate: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
environmentId: "env1",
|
||||
singleUse: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
pin: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockAirtableIntegration: TIntegrationAirtable = {
|
||||
id: "int_airtable",
|
||||
type: "airtable",
|
||||
environmentId: "env1",
|
||||
config: {
|
||||
key: { access_token: "airtable_key" } as TIntegrationAirtableCredential,
|
||||
data: [
|
||||
{
|
||||
surveyId: surveyId,
|
||||
questionIds: [questionId1, questionId2],
|
||||
baseId: "base1",
|
||||
tableId: "table1",
|
||||
createdAt: new Date(),
|
||||
includeHiddenFields: true,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: true,
|
||||
includeVariables: true,
|
||||
} as TIntegrationAirtableConfigData,
|
||||
],
|
||||
} as TIntegrationAirtableConfig,
|
||||
};
|
||||
|
||||
const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
|
||||
id: "int_gsheets",
|
||||
type: "googleSheets",
|
||||
environmentId: "env1",
|
||||
config: {
|
||||
key: { refresh_token: "gsheet_key" } as TIntegrationGoogleSheetsCredential,
|
||||
data: [
|
||||
{
|
||||
surveyId: surveyId,
|
||||
spreadsheetId: "sheet1",
|
||||
spreadsheetName: "Sheet Name",
|
||||
questionIds: [questionId1],
|
||||
questions: "What is Q1?",
|
||||
createdAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: false,
|
||||
includeCreatedAt: false,
|
||||
includeVariables: false,
|
||||
} as TIntegrationGoogleSheetsConfigData,
|
||||
],
|
||||
} as TIntegrationGoogleSheetsConfig,
|
||||
};
|
||||
|
||||
const mockSlackIntegration: TIntegrationSlack = {
|
||||
id: "int_slack",
|
||||
type: "slack",
|
||||
environmentId: "env1",
|
||||
config: {
|
||||
key: { access_token: "slack_key", app_id: "A1" } as TIntegrationSlackCredential,
|
||||
data: [
|
||||
{
|
||||
surveyId: surveyId,
|
||||
channelId: "channel1",
|
||||
channelName: "Channel 1",
|
||||
questionIds: [questionId1, questionId2, questionId3],
|
||||
questions: "Q1, Q2, Q3",
|
||||
createdAt: new Date(),
|
||||
includeHiddenFields: true,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: true,
|
||||
includeVariables: true,
|
||||
} as TIntegrationSlackConfigData,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockNotionIntegration: TIntegrationNotion = {
|
||||
id: "int_notion",
|
||||
type: "notion",
|
||||
environmentId: "env1",
|
||||
config: {
|
||||
key: {
|
||||
access_token: "notion_key",
|
||||
workspace_name: "ws",
|
||||
workspace_icon: "",
|
||||
workspace_id: "w1",
|
||||
} as TIntegrationNotionCredential,
|
||||
data: [
|
||||
{
|
||||
surveyId: surveyId,
|
||||
databaseId: "db1",
|
||||
databaseName: "DB 1",
|
||||
mapping: [
|
||||
{
|
||||
question: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
column: { id: "col1", name: "Column 1", type: "rich_text" },
|
||||
},
|
||||
{
|
||||
question: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
|
||||
column: { id: "col3", name: "Column 3", type: "url" },
|
||||
},
|
||||
{
|
||||
question: { id: "metadata", name: "Metadata", type: "metadata" },
|
||||
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
|
||||
},
|
||||
{
|
||||
question: { id: "createdAt", name: "Created At", type: "createdAt" },
|
||||
column: { id: "col_created", name: "Created Col", type: "date" },
|
||||
},
|
||||
],
|
||||
createdAt: new Date(),
|
||||
} as TIntegrationNotionConfigData,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe("handleIntegrations", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Refine mock to explicitly handle string inputs
|
||||
vi.mocked(processResponseData).mockImplementation((data) => {
|
||||
if (typeof data === "string") {
|
||||
return data; // Directly return string inputs
|
||||
}
|
||||
// Handle arrays and null/undefined as before
|
||||
return String(Array.isArray(data) ? data.join(", ") : (data ?? ""));
|
||||
});
|
||||
vi.mocked(getLocalizedValue).mockImplementation((value, _) => value?.default || "");
|
||||
vi.mocked(parseRecallInfo).mockImplementation((text, _, __) => text || "");
|
||||
vi.mocked(getFormattedDateTimeString).mockReturnValue("2024-01-01 12:00");
|
||||
vi.mocked(truncateText).mockImplementation((text, limit) => text.slice(0, limit));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should call correct handlers for each integration type", async () => {
|
||||
const integrations = [
|
||||
mockAirtableIntegration,
|
||||
mockGoogleSheetsIntegration,
|
||||
mockSlackIntegration,
|
||||
mockNotionIntegration,
|
||||
];
|
||||
vi.mocked(airtableWriteData).mockResolvedValue(undefined);
|
||||
vi.mocked(googleSheetWriteData).mockResolvedValue(undefined);
|
||||
vi.mocked(writeDataToSlack).mockResolvedValue(undefined);
|
||||
vi.mocked(writeNotionData).mockResolvedValue(undefined);
|
||||
|
||||
await handleIntegrations(integrations, mockPipelineInput, mockSurvey);
|
||||
|
||||
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
||||
expect(googleSheetWriteData).toHaveBeenCalledTimes(1);
|
||||
expect(writeDataToSlack).toHaveBeenCalledTimes(1);
|
||||
expect(writeNotionData).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log errors when integration handlers fail", async () => {
|
||||
const integrations = [mockAirtableIntegration, mockSlackIntegration];
|
||||
const airtableError = new Error("Airtable failed");
|
||||
const slackError = new Error("Slack failed");
|
||||
vi.mocked(airtableWriteData).mockRejectedValue(airtableError);
|
||||
vi.mocked(writeDataToSlack).mockRejectedValue(slackError);
|
||||
|
||||
await handleIntegrations(integrations, mockPipelineInput, mockSurvey);
|
||||
|
||||
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
||||
expect(writeDataToSlack).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(airtableError, "Error in airtable integration");
|
||||
expect(logger.error).toHaveBeenCalledWith(slackError, "Error in slack integration");
|
||||
});
|
||||
|
||||
test("should handle empty integrations array", async () => {
|
||||
await handleIntegrations([], mockPipelineInput, mockSurvey);
|
||||
expect(airtableWriteData).not.toHaveBeenCalled();
|
||||
expect(googleSheetWriteData).not.toHaveBeenCalled();
|
||||
expect(writeDataToSlack).not.toHaveBeenCalled();
|
||||
expect(writeNotionData).not.toHaveBeenCalled();
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test individual handlers by calling the main function with a single integration
|
||||
describe("Airtable Integration", () => {
|
||||
test("should call airtableWriteData with correct parameters", async () => {
|
||||
vi.mocked(airtableWriteData).mockResolvedValue(undefined);
|
||||
await handleIntegrations([mockAirtableIntegration], mockPipelineInput, mockSurvey);
|
||||
|
||||
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
||||
// Adjust expectations for metadata and recalled question
|
||||
const expectedMetadataString =
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
|
||||
expect(airtableWriteData).toHaveBeenCalledWith(
|
||||
mockAirtableIntegration.config.key,
|
||||
mockAirtableIntegration.config.data[0],
|
||||
[
|
||||
[
|
||||
"Answer 1",
|
||||
"Choice 1, Choice 2",
|
||||
"Hidden Value",
|
||||
expectedMetadataString,
|
||||
"Variable Value",
|
||||
"2024-01-01 12:00",
|
||||
], // responses + hidden + meta + var + created
|
||||
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"], // questions (raw headline for Airtable) + hidden + meta + var + created
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("should not call airtableWriteData if surveyId does not match", async () => {
|
||||
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
|
||||
await handleIntegrations([mockAirtableIntegration], differentSurveyInput, mockSurvey);
|
||||
|
||||
expect(airtableWriteData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return error result on failure", async () => {
|
||||
const error = new Error("Airtable API error");
|
||||
vi.mocked(airtableWriteData).mockRejectedValue(error);
|
||||
await handleIntegrations([mockAirtableIntegration], mockPipelineInput, mockSurvey);
|
||||
|
||||
// Verify error was logged, remove checks on the return value
|
||||
expect(logger.error).toHaveBeenCalledWith(error, "Error in airtable integration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Sheets Integration", () => {
|
||||
test("should call googleSheetWriteData with correct parameters", async () => {
|
||||
vi.mocked(googleSheetWriteData).mockResolvedValue(undefined);
|
||||
await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey);
|
||||
|
||||
expect(googleSheetWriteData).toHaveBeenCalledTimes(1);
|
||||
// Check that createdAt is converted to Date object
|
||||
const expectedIntegrationData = structuredClone(mockGoogleSheetsIntegration);
|
||||
expectedIntegrationData.config.data[0].createdAt = new Date(
|
||||
mockGoogleSheetsIntegration.config.data[0].createdAt
|
||||
);
|
||||
expect(googleSheetWriteData).toHaveBeenCalledWith(
|
||||
expectedIntegrationData,
|
||||
mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
|
||||
[
|
||||
["Answer 1"], // responses
|
||||
["Question 1 {{recall:q2}}"], // questions (raw headline for Google Sheets)
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("should not call googleSheetWriteData if surveyId does not match", async () => {
|
||||
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
|
||||
await handleIntegrations([mockGoogleSheetsIntegration], differentSurveyInput, mockSurvey);
|
||||
|
||||
expect(googleSheetWriteData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return error result on failure", async () => {
|
||||
const error = new Error("Google Sheets API error");
|
||||
vi.mocked(googleSheetWriteData).mockRejectedValue(error);
|
||||
await handleIntegrations([mockGoogleSheetsIntegration], mockPipelineInput, mockSurvey);
|
||||
|
||||
// Verify error was logged, remove checks on the return value
|
||||
expect(logger.error).toHaveBeenCalledWith(error, "Error in google sheets integration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slack Integration", () => {
|
||||
test("should not call writeDataToSlack if surveyId does not match", async () => {
|
||||
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
|
||||
await handleIntegrations([mockSlackIntegration], differentSurveyInput, mockSurvey);
|
||||
|
||||
expect(writeDataToSlack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return error result on failure", async () => {
|
||||
const error = new Error("Slack API error");
|
||||
vi.mocked(writeDataToSlack).mockRejectedValue(error);
|
||||
await handleIntegrations([mockSlackIntegration], mockPipelineInput, mockSurvey);
|
||||
|
||||
// Verify error was logged, remove checks on the return value
|
||||
expect(logger.error).toHaveBeenCalledWith(error, "Error in slack integration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Notion Integration", () => {
|
||||
test("should not call writeNotionData if surveyId does not match", async () => {
|
||||
const differentSurveyInput = { ...mockPipelineInput, surveyId: "otherSurvey" };
|
||||
await handleIntegrations([mockNotionIntegration], differentSurveyInput, mockSurvey);
|
||||
|
||||
expect(writeNotionData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return error result on failure", async () => {
|
||||
const error = new Error("Notion API error");
|
||||
vi.mocked(writeNotionData).mockRejectedValue(error);
|
||||
await handleIntegrations([mockNotionIntegration], mockPipelineInput, mockSurvey);
|
||||
|
||||
// Verify error was logged, remove checks on the return value
|
||||
expect(logger.error).toHaveBeenCalledWith(error, "Error in notion integration");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
mockResponseWithContactQuestion,
|
||||
mockSurvey,
|
||||
mockSurveyWithContactQuestion,
|
||||
} from "@/app/api/(internal)/pipeline/lib/tests/__mocks__/survey-follow-up.mock";
|
||||
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { evaluateFollowUp, sendSurveyFollowUps } from "../survey-follow-up";
|
||||
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/email", () => ({
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
570
apps/web/app/api/cron/weekly-summary/lib/project.test.ts
Normal file
570
apps/web/app/api/cron/weekly-summary/lib/project.test.ts
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
62
apps/web/app/api/v1/management/me/lib/utils.test.ts
Normal file
62
apps/web/app/api/v1/management/me/lib/utils.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
121
apps/web/app/api/v1/management/responses/lib/contact.test.ts
Normal file
121
apps/web/app/api/v1/management/responses/lib/contact.test.ts
Normal file
@@ -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)],
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
347
apps/web/app/api/v1/management/responses/lib/response.test.ts
Normal file
347
apps/web/app/api/v1/management/responses/lib/response.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
187
apps/web/app/api/v1/management/surveys/lib/surveys.test.ts
Normal file
187
apps/web/app/api/v1/management/surveys/lib/surveys.test.ts
Normal file
@@ -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
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user