mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-24 15:10:36 -06:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aeb92eb4f | ||
|
|
00dfa629b5 | ||
|
|
3ca471b6a2 | ||
|
|
a525589186 | ||
|
|
59ed10398d | ||
|
|
25a86e31df | ||
|
|
7d6743a81a | ||
|
|
6616f62da5 | ||
|
|
a3cbc05e12 | ||
|
|
97095a627a | ||
|
|
910d257c56 | ||
|
|
0c0a008b28 | ||
|
|
9879458353 | ||
|
|
d44f1f3b4b | ||
|
|
c5d387a7e5 | ||
|
|
a6aacd5c55 | ||
|
|
57e7485564 | ||
|
|
42a38a6f47 | ||
|
|
34bb9c2127 | ||
|
|
6442b5e4aa | ||
|
|
dde5a55446 | ||
|
|
13e615a798 | ||
|
|
9c81961b0b | ||
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 | ||
|
|
fcbb97010c | ||
|
|
6be46b16b2 | ||
|
|
35b2356a31 | ||
|
|
53ef756723 | ||
|
|
0f0b743a10 | ||
|
|
3f7dafb65c | ||
|
|
9df791b5ff | ||
|
|
dea40d9757 | ||
|
|
dd12a589d6 | ||
|
|
af6e5ba31e | ||
|
|
2b57b2080b | ||
|
|
154c85a0f7 | ||
|
|
3f465d4594 | ||
|
|
94e883f4c3 | ||
|
|
38622101f1 | ||
|
|
0eb64c0084 | ||
|
|
409f5b1791 | ||
|
|
14398a9c4f | ||
|
|
d1cdf6e216 | ||
|
|
65da25a626 | ||
|
|
ce8b019e93 | ||
|
|
67d7fe016d | ||
|
|
47583b5a32 | ||
|
|
03c9a6aaae | ||
|
|
4dcf9b093b | ||
|
|
5ba5ebf63d | ||
|
|
115bea2792 | ||
|
|
b0495a8a42 | ||
|
|
faabd371f5 | ||
|
|
f0be6de0b3 | ||
|
|
b338c6d28d | ||
|
|
07e9a7c007 | ||
|
|
928bb3f8bc | ||
|
|
b9d62f6af2 | ||
|
|
f7ac38953b | ||
|
|
6441c0aa31 | ||
|
|
16479eb6cf | ||
|
|
69472c21c2 |
11
.env.example
11
.env.example
@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Automatically assign new users to a specific organization and role within that organization
|
||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||
# (Role Management is an Enterprise feature)
|
||||
# DEFAULT_ORGANIZATION_ROLE=owner
|
||||
# AUTH_SSO_DEFAULT_TEAM_ID=
|
||||
# AUTH_SKIP_INVITE_FOR_SSO=
|
||||
|
||||
@@ -191,8 +190,7 @@ UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
@@ -200,9 +198,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
@@ -216,5 +211,5 @@ UNKEY_ROOT_KEY=
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Disable the user management from UI
|
||||
# DISABLE_USER_MANAGEMENT=1
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -11,7 +11,7 @@ When generating test files inside the "/app/web" path, follow these rules:
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- 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.
|
||||
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
|
||||
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
|
||||
@@ -28,4 +28,5 @@ afterEach(() => {
|
||||
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
|
||||
- For click events, import userEvent from "@testing-library/user-event"
|
||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||
- You don't need to mock @tolgee/react
|
||||
- You don't need to mock @tolgee/react
|
||||
- Use "import "@testing-library/jest-dom/vitest";"
|
||||
84
.github/dependabot.yml
vendored
84
.github/dependabot.yml
vendored
@@ -1,84 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
|
||||
directory: "/" # Root package.json
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
versioning-strategy: increase
|
||||
|
||||
# Apps directory packages
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo-react-native"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/storybook"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Packages directory
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/database"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/lib"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/types"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-eslint"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-prettier"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-typescript"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/js-core"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/surveys"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/logger"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
5
.github/workflows/chromatic.yml
vendored
5
.github/workflows/chromatic.yml
vendored
@@ -10,6 +10,11 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
|
||||
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0
|
||||
|
||||
10
.github/workflows/deploy-formbricks-cloud.yml
vendored
10
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release'
|
||||
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
|
||||
if: inputs.ENVIRONMENT == 'prod'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -75,6 +75,7 @@ jobs:
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -84,13 +85,14 @@ jobs:
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
|
||||
if: inputs.ENVIRONMENT == 'stage'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
|
||||
23
.github/workflows/e2e.yml
vendored
23
.github/workflows/e2e.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
# Add other secrets if necessary
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -23,7 +25,6 @@ permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -48,15 +49,17 @@ jobs:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
egress-policy: allow
|
||||
allowed-endpoints: |
|
||||
ee.formbricks.com:443
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
@@ -75,7 +78,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -89,8 +92,18 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
if [ -z "$LICENSE_KEY" ]; then
|
||||
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
|
||||
exit 1
|
||||
fi
|
||||
echo "License key length: ${#LICENSE_KEY}"
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
|
||||
sleep 10 # Optional: gives some buffer for the app to start
|
||||
for attempt in {1..10}; do
|
||||
|
||||
2
.github/workflows/formbricks-release.yml
vendored
2
.github/workflows/formbricks-release.yml
vendored
@@ -30,5 +30,5 @@ jobs:
|
||||
- docker-build
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
5
.github/workflows/release-docker-github.yml
vendored
5
.github/workflows/release-docker-github.yml
vendored
@@ -20,18 +20,15 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
2
.github/workflows/release-helm-chart.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Helm chart to release'
|
||||
description: "The version of the Helm chart to release"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
4
.github/workflows/semantic-pull-requests.yml
vendored
4
.github/workflows/semantic-pull-requests.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
revert
|
||||
ossgg
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
# When the previous steps fails, the workflow would stop. By adding this
|
||||
# condition you can continue the execution with the populated error message.
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
|
||||
2
.github/workflows/sonarqube.yml
vendored
2
.github/workflows/sonarqube.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
18
.github/workflows/terraform-plan-and-apply.yml
vendored
18
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: 'Terraform'
|
||||
name: "Terraform"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -14,14 +14,13 @@ on:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
@@ -41,7 +40,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
@@ -71,7 +70,7 @@ jobs:
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
@@ -83,4 +82,3 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ConnectWithFormbricks } from "./ConnectWithFormbricks";
|
||||
|
||||
// Mocks before import
|
||||
const pushMock = vi.fn();
|
||||
const refreshMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) }));
|
||||
vi.mock("./OnboardingSetupInstructions", () => ({
|
||||
OnboardingSetupInstructions: () => <div data-testid="instructions" />,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConnectWithFormbricks", () => {
|
||||
const environment = { id: "env1" } as any;
|
||||
const webAppUrl = "http://app";
|
||||
const channel = {} as any;
|
||||
|
||||
test("renders waiting state when widgetSetupCompleted is false", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("instructions")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success state when widgetSetupCompleted is true", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking finish button navigates to surveys", async () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" });
|
||||
await userEvent.click(button);
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`);
|
||||
});
|
||||
|
||||
test("refresh is called on visibilitychange to visible", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if session is missing", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
});
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user lacks access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
})
|
||||
).rejects.toThrow("User is not authorized to access this environment");
|
||||
});
|
||||
|
||||
test("renders children if user has access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div data-testid="child">Test Content</div>,
|
||||
});
|
||||
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => {
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!isAuthorized) {
|
||||
throw AuthorizationError;
|
||||
throw new AuthorizationError("User is not authorized to access this environment");
|
||||
}
|
||||
|
||||
return <div className="flex-1 bg-slate-50">{children}</div>;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { XMTemplateList } from "./XMTemplateList";
|
||||
|
||||
// Prepare push mock and module mocks before importing component
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
|
||||
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
|
||||
getXMTemplates: (t: any) => [
|
||||
{ id: 1, name: "tmpl1" },
|
||||
{ id: 2, name: "tmpl2" },
|
||||
],
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
|
||||
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
|
||||
}));
|
||||
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div>
|
||||
{options.map((opt, idx) => (
|
||||
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
|
||||
{opt.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Reset mocks between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("XMTemplateList component", () => {
|
||||
const project = { id: "proj1" } as any;
|
||||
const user = { id: "user1" } as any;
|
||||
const environmentId = "env1";
|
||||
|
||||
test("creates survey and navigates on success", async () => {
|
||||
// Mock successful survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option0 = screen.getByTestId("option-0");
|
||||
await userEvent.click(option0);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
|
||||
});
|
||||
|
||||
test("shows error toast on failure", async () => {
|
||||
// Mock failed survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option1 = screen.getByTestId("option-1");
|
||||
await userEvent.click(option1);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { replacePresetPlaceholders } from "./utils";
|
||||
|
||||
// Mock data
|
||||
const mockProject: TProject = {
|
||||
id: "project1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Project",
|
||||
organizationId: "org1",
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: "#FFFFFF" },
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
config: {
|
||||
channel: "link" as const,
|
||||
industry: "eCommerce" as "eCommerce" | "saas" | "other" | null,
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
logo: null,
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
inputType: "text",
|
||||
type: "email" as any,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
required: false,
|
||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "e1",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you for completing the survey!" },
|
||||
},
|
||||
],
|
||||
styling: {
|
||||
brandColor: { light: "#0000FF" },
|
||||
questionColor: { light: "#00FF00" },
|
||||
inputColor: { light: "#FF0000" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("replacePresetPlaceholders", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in template name", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.name).toBe("Test Project Survey");
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in question headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
||||
});
|
||||
|
||||
test("returns a new object without mutating the original template", () => {
|
||||
const originalTemplate = structuredClone(mockTemplate);
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result).not.toBe(mockTemplate);
|
||||
expect(mockTemplate).toEqual(originalTemplate);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("xm-templates", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("getXMSurveyDefault returns default survey template", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMSurveyDefault(tMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
});
|
||||
expect(result.endings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("getXMTemplates returns all templates", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result[0].name).toBe("templates.nps_survey_name");
|
||||
expect(result[1].name).toBe("templates.star_rating_survey_name");
|
||||
expect(result[2].name).toBe("templates.csat_survey_name");
|
||||
expect(result[3].name).toBe("templates.cess_survey_name");
|
||||
expect(result[4].name).toBe("templates.smileys_survey_name");
|
||||
expect(result[5].name).toBe("templates.enps_survey_name");
|
||||
});
|
||||
|
||||
test("getXMTemplates handles errors gracefully", async () => {
|
||||
const tMock = vi.fn(() => {
|
||||
throw new Error("Test error");
|
||||
}) as TFnType;
|
||||
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
// Dynamically import the mocked logger
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to load XM templates, returning empty array"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
58
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
58
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getTeamsByOrganizationId } from "./onboarding";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
team: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: (fn: any) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/team", () => ({
|
||||
teamCache: {
|
||||
tag: { byOrganizationId: vi.fn((id: string) => `organization-${id}-teams`) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getTeamsByOrganizationId", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns mapped teams", async () => {
|
||||
const mockTeams = [
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
];
|
||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||
const result = await getTeamsByOrganizationId("org1");
|
||||
expect(result).toEqual([
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
);
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error on unknown error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail"));
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { LandingSidebar } from "./landing-sidebar";
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) => (
|
||||
<div data-testid={open ? "modal-open" : "modal-closed"} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
|
||||
}));
|
||||
|
||||
// Ensure mocks are reset between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
{ id: "o1", name: "alphaOrg" },
|
||||
] as any;
|
||||
|
||||
test("renders logo, avatar, and initial modal closed", () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Formbricks logo
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Profile avatar
|
||||
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
|
||||
// CreateOrganizationModal should be closed initially
|
||||
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking logout triggers signOut", async () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open user dropdown by clicking on avatar trigger
|
||||
const trigger = screen.getByTestId("avatar").parentElement;
|
||||
if (trigger) await userEvent.click(trigger);
|
||||
|
||||
// Click logout menu item
|
||||
const logoutItem = await screen.findByText("common.logout");
|
||||
await userEvent.click(logoutItem);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import LandingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
vi.mock("@/lib/membership/service");
|
||||
vi.mock("@/lib/project/service");
|
||||
vi.mock("next-auth");
|
||||
vi.mock("next/navigation");
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("LandingLayout", () => {
|
||||
test("redirects to login if no session exists", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns notFound if no membership is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(notFound)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects to production environment if available", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([
|
||||
{
|
||||
id: "proj-123",
|
||||
organizationId: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
name: "Project 1",
|
||||
styling: { allowStyleOverwrite: true },
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
} as any,
|
||||
]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([
|
||||
{
|
||||
id: "env-123",
|
||||
type: "production",
|
||||
projectId: "proj-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
|
||||
});
|
||||
|
||||
test("renders children if no projects or production environment exist", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
const result = await LandingLayout(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
<>
|
||||
<div>Child Content</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||
}));
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: (fn: any) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("returns notFound if user does not exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: {},
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
expect(result).toBe("NOT_FOUND_STUB");
|
||||
});
|
||||
|
||||
test("renders header and sidebar for authenticated user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: { id: "org1" },
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
|
||||
|
||||
const result = await Page({ params });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("renders header, options, and close button when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header title and subtitle
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.channel.channel_select_title"
|
||||
);
|
||||
expect(
|
||||
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Options container with correct titles
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.channel.link_and_email_surveys," +
|
||||
"organizations.projects.new.channel.in_product_surveys"
|
||||
);
|
||||
|
||||
// Close button link rendered when projects >=1
|
||||
const closeLink = screen.getByRole("link");
|
||||
expect(closeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("does not render close button when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getOrganizationProjectsCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if no session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns not found if user is member or billing", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws error if organization is not found", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
test("redirects to home if project limit is reached", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
test("renders children when all conditions are met", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
const result = await OnboardingLayout(props);
|
||||
expect(result).toEqual(<>{props.children}</>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: any) => (
|
||||
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
describe("Mode Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("renders header and options without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.mode.what_are_you_here_for"
|
||||
);
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
|
||||
);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ProjectSettings } from "./ProjectSettings";
|
||||
|
||||
// Mocks before imports
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/modules/ui/components/color-picker", () => ({
|
||||
ColorPicker: ({ color, onChange }: any) => (
|
||||
<button data-testid="color-picker" onClick={() => onChange("#000")}>
|
||||
{color}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/input", () => ({
|
||||
Input: ({ value, onChange, placeholder }: any) => (
|
||||
<input placeholder={placeholder} value={value} onChange={(e) => onChange((e.target as any).value)} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/multi-select", () => ({
|
||||
MultiSelect: ({ value, options, onChange }: any) => (
|
||||
<select
|
||||
data-testid="multi-select"
|
||||
multiple
|
||||
value={value}
|
||||
onChange={(e) => onChange(Array.from((e.target as any).selectedOptions).map((o: any) => o.value))}>
|
||||
{options.map((o: any) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/survey", () => ({
|
||||
SurveyInline: () => <div data-testid="survey-inline" />,
|
||||
}));
|
||||
vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) }));
|
||||
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
|
||||
CreateTeamModal: ({ open }: any) => <div data-testid={open ? "team-modal-open" : "team-modal-closed"} />,
|
||||
}));
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("ProjectSettings component", () => {
|
||||
const baseProps = {
|
||||
organizationId: "org1",
|
||||
projectMode: "cx",
|
||||
industry: "ind",
|
||||
defaultBrandColor: "#fff",
|
||||
organizationTeams: [],
|
||||
canDoRoleManagement: false,
|
||||
userProjectsCount: 0,
|
||||
} as any;
|
||||
|
||||
const fillAndSubmit = async () => {
|
||||
const nameInput = screen.getByPlaceholderText("e.g. Formbricks");
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "TestProject");
|
||||
const nextButton = screen.getByRole("button", { name: "common.next" });
|
||||
await userEvent.click(nextButton);
|
||||
};
|
||||
|
||||
test("successful createProject for link channel navigates to surveys and clears localStorage", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env123", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(createProjectAction).toHaveBeenCalledWith({
|
||||
organizationId: "org1",
|
||||
data: expect.objectContaining({ teamIds: [] }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys");
|
||||
expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull();
|
||||
});
|
||||
|
||||
test("successful createProject for app channel navigates to connect", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env456", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="app" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect");
|
||||
});
|
||||
|
||||
test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({
|
||||
data: { environments: [{ id: "env789", type: "production" }] },
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="unknown" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates");
|
||||
});
|
||||
|
||||
test("shows error toast on createProject error response", async () => {
|
||||
(createProjectAction as any).mockResolvedValue({ error: "err" });
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
|
||||
test("shows error toast on exception", async () => {
|
||||
(createProjectAction as any).mockImplementation(() => {
|
||||
throw new Error("fail");
|
||||
});
|
||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
||||
await fillAndSubmit();
|
||||
expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed");
|
||||
});
|
||||
});
|
||||
@@ -225,7 +225,7 @@ export const ProjectSettings = ({
|
||||
alt="Logo"
|
||||
width={256}
|
||||
height={56}
|
||||
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
||||
// Mocks before component import
|
||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: any) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
|
||||
() => ({
|
||||
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
|
||||
})
|
||||
);
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ProjectSettingsPage", () => {
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
|
||||
|
||||
test("redirects to login when no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params, searchParams });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws when teams not found", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
||||
|
||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||
});
|
||||
|
||||
test("renders header, settings and close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.settings.project_settings_title"
|
||||
);
|
||||
// ProjectSettings stub receives mode prop
|
||||
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
|
||||
// Close link for existing projects
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("renders without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Home, Settings } from "lucide-react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
|
||||
|
||||
describe("OnboardingOptionsContainer", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders options with links", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Test Option",
|
||||
description: "Test Description",
|
||||
icon: Home,
|
||||
href: "/test",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Test Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with onClick handler", () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Click Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Click Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with iconText", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Icon Text Option",
|
||||
description: "Icon Text Description",
|
||||
icon: Home,
|
||||
iconText: "Custom Icon Text",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with loading state", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Loading Option",
|
||||
description: "Loading Description",
|
||||
icon: Home,
|
||||
isLoading: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Loading Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders multiple options", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "First Option",
|
||||
description: "First Description",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Second Option",
|
||||
description: "Second Description",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("First Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onClick handler when clicking an option", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
await userEvent.click(screen.getByText("Click Option"));
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -33,7 +33,7 @@ const Loading = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -49,7 +48,6 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
mockIsDevelopment = false;
|
||||
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with default props", async () => {
|
||||
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...mockLicense,
|
||||
isPendingDowngrade: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
});
|
||||
|
||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
||||
// Ensure the license mock reflects the condition needed for the banner
|
||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
|
||||
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.user_not_found"
|
||||
);
|
||||
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"environments.projects_environments_organizations_not_found"
|
||||
);
|
||||
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
|
||||
test("throws error if member has no project permission", async () => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.project_permission_not_found"
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
|
||||
@@ -109,7 +109,7 @@ export const MainNavigation = ({
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed ? true : false);
|
||||
setIsTextVisible(isCollapsed);
|
||||
};
|
||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
@@ -170,7 +170,7 @@ export const MainNavigation = ({
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
@@ -264,7 +264,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableCredential,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown",
|
||||
() => ({
|
||||
BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => (
|
||||
<div>
|
||||
<label htmlFor="base">Base</label>
|
||||
<select
|
||||
id="base"
|
||||
defaultValue={defaultValue}
|
||||
onChange={(e) => {
|
||||
control._mockOnChange({ target: { name: "base", value: e.target.value } });
|
||||
setValue("table", ""); // Reset table when base changes
|
||||
fetchTable(e.target.value);
|
||||
}}>
|
||||
<option value="">Select Base</option>
|
||||
{airtableArray.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
|
||||
fetchTables: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value, _locale) => value?.default || value || "",
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey, _locale) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
AdditionalIntegrationSettings: ({
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
setIncludeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}) => (
|
||||
<div data-testid="additional-settings">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-variables"
|
||||
checked={includeVariables}
|
||||
onChange={(e) => setIncludeVariables(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-hidden"
|
||||
checked={includeHiddenFields}
|
||||
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-metadata"
|
||||
checked={includeMetadata}
|
||||
onChange={(e) => setIncludeMetadata(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="include-createdat"
|
||||
checked={includeCreatedAt}
|
||||
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen }) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
|
||||
AlertTitle: ({ children }) => <div data-testid="alert-title">{children}</div>,
|
||||
AlertDescription: ({ children }) => <div data-testid="alert-description">{children}</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: (props) => <img alt="test" {...props} />,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ refresh: vi.fn() })),
|
||||
}));
|
||||
|
||||
// Mock the Select component used for Table and Survey selections
|
||||
vi.mock("@/modules/ui/components/select", () => ({
|
||||
Select: ({ children }) => (
|
||||
// Render children, assuming Controller passes props to the Trigger/Value
|
||||
// The actual select logic will be handled by the mocked Controller/field
|
||||
// We need to simulate the structure expected by the Controller render prop
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectTrigger: ({ children, ...props }) => <div {...props}>{children}</div>, // Mock Trigger
|
||||
SelectValue: ({ placeholder }) => <span>{placeholder || "Select..."}</span>, // Mock Value display
|
||||
SelectContent: ({ children }) => <div>{children}</div>, // Mock Content wrapper
|
||||
SelectItem: ({ children, value, ...props }) => (
|
||||
// Mock Item - crucial for userEvent.selectOptions if we were using a real select
|
||||
// For Controller, the value change is handled by field.onChange directly
|
||||
<div data-value={value} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-hook-form Controller to render a simple select
|
||||
vi.mock("react-hook-form", async () => {
|
||||
const actual = await vi.importActual("react-hook-form");
|
||||
let fields = {};
|
||||
const mockReset = vi.fn((values) => {
|
||||
fields = values || {}; // Reset fields, optionally with new values
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useForm: vi.fn((options) => {
|
||||
fields = options?.defaultValues || {};
|
||||
const mockControlOnChange = (event) => {
|
||||
if (event && event.target) {
|
||||
fields[event.target.name] = event.target.value;
|
||||
}
|
||||
};
|
||||
return {
|
||||
handleSubmit: (fn) => (e) => {
|
||||
e?.preventDefault();
|
||||
fn(fields);
|
||||
},
|
||||
control: {
|
||||
_mockOnChange: mockControlOnChange,
|
||||
// Add other necessary control properties if needed
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
getFieldState: vi.fn(() => ({ invalid: false, isDirty: false, isTouched: false, error: null })),
|
||||
_names: { mount: new Set(), unMount: new Set(), array: new Set(), watch: new Set() },
|
||||
_options: {},
|
||||
_proxyFormState: {
|
||||
isDirty: false,
|
||||
isValidating: false,
|
||||
dirtyFields: {},
|
||||
touchedFields: {},
|
||||
errors: {},
|
||||
},
|
||||
_formState: { isDirty: false, isValidating: false, dirtyFields: {}, touchedFields: {}, errors: {} },
|
||||
_updateFormState: vi.fn(),
|
||||
_updateFieldArray: vi.fn(),
|
||||
_executeSchema: vi.fn().mockResolvedValue({ errors: {}, values: {} }),
|
||||
_getWatch: vi.fn(),
|
||||
_subjects: {
|
||||
watch: { subscribe: vi.fn() },
|
||||
array: { subscribe: vi.fn() },
|
||||
state: { subscribe: vi.fn() },
|
||||
},
|
||||
_getDirty: vi.fn(),
|
||||
_reset: vi.fn(),
|
||||
_removeUnmounted: vi.fn(),
|
||||
},
|
||||
watch: (name) => fields[name],
|
||||
setValue: (name, value) => {
|
||||
fields[name] = value;
|
||||
},
|
||||
reset: mockReset,
|
||||
formState: { errors: {}, isDirty: false, isValid: true, isSubmitting: false },
|
||||
getValues: (name) => (name ? fields[name] : fields),
|
||||
};
|
||||
}),
|
||||
Controller: ({ name, defaultValue }) => {
|
||||
// Initialize field value if not already set by reset/defaultValues
|
||||
if (fields[name] === undefined && defaultValue !== undefined) {
|
||||
fields[name] = defaultValue;
|
||||
}
|
||||
|
||||
const field = {
|
||||
onChange: (valueOrEvent) => {
|
||||
const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent;
|
||||
fields[name] = value;
|
||||
// Re-render might be needed here in a real scenario, but testing library handles it
|
||||
},
|
||||
onBlur: vi.fn(),
|
||||
value: fields[name],
|
||||
name: name,
|
||||
ref: vi.fn(),
|
||||
};
|
||||
|
||||
// Find the corresponding label to associate with the select
|
||||
const labelId = name; // Assuming label 'for' matches field name
|
||||
const labelText =
|
||||
name === "table" ? "environments.integrations.airtable.table_name" : "common.select_survey";
|
||||
|
||||
// Render a simple select element instead of the complex component
|
||||
// This makes interaction straightforward with userEvent.selectOptions
|
||||
return (
|
||||
<>
|
||||
{/* The actual label is rendered outside the Controller in the component */}
|
||||
<select
|
||||
id={labelId}
|
||||
aria-label={labelText} // Use aria-label for accessibility in tests
|
||||
{...field} // Spread field props
|
||||
defaultValue={defaultValue} // Pass defaultValue
|
||||
>
|
||||
{/* Need to dynamically get options based on context, simplified here */}
|
||||
{name === "table" &&
|
||||
mockTables.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
{name === "survey" &&
|
||||
mockSurveys.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
);
|
||||
},
|
||||
reset: mockReset,
|
||||
};
|
||||
});
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
questions: [
|
||||
{ id: "q1", headline: { default: "Question 1" } },
|
||||
{ id: "q2", headline: { default: "Question 2" } },
|
||||
],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
|
||||
variables: { enabled: true, fieldIds: ["var1"] },
|
||||
} as any,
|
||||
{
|
||||
id: "survey2",
|
||||
name: "Survey 2",
|
||||
questions: [{ id: "q3", headline: { default: "Question 3" } }],
|
||||
hiddenFields: { enabled: false },
|
||||
variables: { enabled: false },
|
||||
} as any,
|
||||
];
|
||||
const mockAirtableArray: TIntegrationItem[] = [
|
||||
{ id: "base1", name: "Base 1" },
|
||||
{ id: "base2", name: "Base 2" },
|
||||
];
|
||||
const mockAirtableIntegration: TIntegrationAirtable = {
|
||||
id: "integration1",
|
||||
type: "airtable",
|
||||
environmentId,
|
||||
config: {
|
||||
key: { access_token: "abc" } as TIntegrationAirtableCredential,
|
||||
email: "test@test.com",
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
const mockTables: TIntegrationAirtableTables["tables"] = [
|
||||
{ id: "table1", name: "Table 1" },
|
||||
{ id: "table2", name: "Table 2" },
|
||||
];
|
||||
const mockSetOpenWithStates = vi.fn();
|
||||
const mockRouterRefresh = vi.fn();
|
||||
|
||||
describe("AddIntegrationModal", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useRouter).mockReturnValue({ refresh: mockRouterRefresh } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders in add mode correctly", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.link_airtable_table")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Base")).toBeInTheDocument();
|
||||
// Use getByLabelText for the mocked selects
|
||||
expect(screen.getByLabelText("environments.integrations.airtable.table_name")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("common.select_survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.save")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.delete")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'No Base Found' error when airtableArray is empty", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={[]}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("alert-title")).toHaveTextContent(
|
||||
"environments.integrations.airtable.no_bases_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("shows 'No Surveys Found' warning when surveys array is empty", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={[]}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.create_survey_warning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("fetches and displays tables when a base is selected", async () => {
|
||||
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const baseSelect = screen.getByLabelText("Base");
|
||||
await userEvent.selectOptions(baseSelect, "base1");
|
||||
|
||||
expect(fetchTables).toHaveBeenCalledWith(environmentId, "base1");
|
||||
await waitFor(() => {
|
||||
// Use getByLabelText (mocked select)
|
||||
const tableSelect = screen.getByLabelText("environments.integrations.airtable.table_name");
|
||||
expect(tableSelect).toBeEnabled();
|
||||
// Check options within the mocked select
|
||||
expect(tableSelect.querySelector("option[value='table1']")).toBeInTheDocument();
|
||||
expect(tableSelect.querySelector("option[value='table2']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles deletion in edit mode", async () => {
|
||||
const initialData: TIntegrationAirtableConfigData = {
|
||||
baseId: "base1",
|
||||
tableId: "table1",
|
||||
surveyId: "survey1",
|
||||
questionIds: ["q1"],
|
||||
questions: "common.selected_questions",
|
||||
tableName: "Table 1",
|
||||
surveyName: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
includeVariables: false,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: false,
|
||||
includeCreatedAt: true,
|
||||
};
|
||||
const integrationWithData = {
|
||||
...mockAirtableIntegration,
|
||||
config: { ...mockAirtableIntegration.config, data: [initialData] },
|
||||
};
|
||||
const defaultData = { ...initialData, index: 0 } as any;
|
||||
|
||||
vi.mocked(fetchTables).mockResolvedValue({ tables: mockTables });
|
||||
vi.mocked(createOrUpdateIntegrationAction).mockResolvedValue({ ok: true, data: {} } as any);
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={integrationWithData}
|
||||
isEditMode={true}
|
||||
defaultData={defaultData}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetchTables).toHaveBeenCalled()); // Wait for initial load
|
||||
|
||||
// Click delete
|
||||
await userEvent.click(screen.getByText("common.delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledTimes(1);
|
||||
const submittedData = vi.mocked(createOrUpdateIntegrationAction).mock.calls[0][0].integrationData;
|
||||
// Expect data array to be empty after deletion
|
||||
expect(submittedData.config.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
|
||||
expect(mockRouterRefresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles cancel button click", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
open={true}
|
||||
setOpenWithStates={mockSetOpenWithStates}
|
||||
environmentId={environmentId}
|
||||
airtableArray={mockAirtableArray}
|
||||
surveys={mockSurveys}
|
||||
airtableIntegration={mockAirtableIntegration}
|
||||
isEditMode={false}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("common.cancel"));
|
||||
expect(mockSetOpenWithStates).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "./AirtableWrapper";
|
||||
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration",
|
||||
() => ({
|
||||
ManageIntegration: ({ setIsConnected }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setIsConnected(false)}>Disconnect</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: ({ handleAuthorization, isEnabled }) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization} disabled={!isEnabled}>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock library function
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock image import
|
||||
vi.mock("@/images/airtableLogo.svg", () => ({
|
||||
default: "airtable-logo-path",
|
||||
}));
|
||||
|
||||
// Mock window.location.replace
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
replace: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const webAppUrl = "https://app.formbricks.com";
|
||||
const environment = { id: environmentId } as TEnvironment;
|
||||
const surveys = [];
|
||||
const airtableArray = [];
|
||||
const locale = "en-US" as const;
|
||||
|
||||
const baseProps = {
|
||||
environmentId,
|
||||
airtableArray,
|
||||
surveys,
|
||||
environment,
|
||||
webAppUrl,
|
||||
locale,
|
||||
};
|
||||
|
||||
describe("AirtableWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected (no integration)", () => {
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected (integration without key)", () => {
|
||||
const integrationWithoutKey = { config: {} } as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={integrationWithoutKey} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration disabled when isEnabled is false", () => {
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={false} airtableIntegration={undefined} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("calls authorize and redirects when Connect button is clicked", async () => {
|
||||
const mockAuthorize = vi.mocked(authorize);
|
||||
const redirectUrl = "https://airtable.com/auth";
|
||||
mockAuthorize.mockResolvedValue(redirectUrl);
|
||||
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={undefined} />);
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await userEvent.click(connectButton);
|
||||
|
||||
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
|
||||
await vi.waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders ManageIntegration when connected", () => {
|
||||
const connectedIntegration = {
|
||||
id: "int-1",
|
||||
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
|
||||
const connectedIntegration = {
|
||||
id: "int-1",
|
||||
config: { key: { access_token: "abc" }, email: "test@test.com", data: [] },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
render(<AirtableWrapper {...baseProps} isEnabled={true} airtableIntegration={connectedIntegration} />);
|
||||
|
||||
// Initially, ManageIntegration is shown
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
|
||||
// Simulate disconnection via ManageIntegration's button
|
||||
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
|
||||
await userEvent.click(disconnectButton);
|
||||
|
||||
// Now, ConnectIntegration should be shown
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { IntegrationModalInputs } from "./AddIntegrationModal";
|
||||
import { BaseSelectDropdown } from "./BaseSelectDropdown";
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children, htmlFor }: { children: React.ReactNode; htmlFor: string }) => (
|
||||
<label htmlFor={htmlFor}>{children}</label>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/select", () => ({
|
||||
Select: ({ children, onValueChange, disabled, defaultValue }) => (
|
||||
<select
|
||||
data-testid="base-select"
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
defaultValue={defaultValue}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }) => <div>{children}</div>,
|
||||
SelectValue: () => <span>SelectValueMock</span>,
|
||||
SelectContent: ({ children }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }) => <option value={value}>{children}</option>,
|
||||
}));
|
||||
|
||||
// Mock react-hook-form's Controller specifically
|
||||
vi.mock("react-hook-form", async () => {
|
||||
const actual = await vi.importActual("react-hook-form");
|
||||
// Keep the actual useForm
|
||||
const originalUseForm = actual.useForm;
|
||||
|
||||
// Mock Controller
|
||||
const MockController = ({ name, _, render, defaultValue }) => {
|
||||
// Minimal mock: call render with a basic field object
|
||||
const field = {
|
||||
onChange: vi.fn(), // Simple spy for field.onChange
|
||||
onBlur: vi.fn(),
|
||||
value: defaultValue, // Use defaultValue passed to Controller
|
||||
name: name,
|
||||
ref: vi.fn(),
|
||||
};
|
||||
// The component passes the render prop result to the actual Select component
|
||||
return render({ field });
|
||||
};
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useForm: originalUseForm, // Use the actual useForm
|
||||
Controller: MockController, // Use the mocked Controller
|
||||
};
|
||||
});
|
||||
|
||||
const mockAirtableArray: TIntegrationItem[] = [
|
||||
{ id: "base1", name: "Base One" },
|
||||
{ id: "base2", name: "Base Two" },
|
||||
];
|
||||
|
||||
const mockFetchTable = vi.fn();
|
||||
|
||||
// Use a wrapper component that utilizes the actual useForm
|
||||
const renderComponent = (
|
||||
isLoading = false,
|
||||
defaultValue: string | undefined = undefined,
|
||||
airtableArray = mockAirtableArray
|
||||
) => {
|
||||
const Component = () => {
|
||||
// Now uses the actual useForm because Controller is mocked separately
|
||||
const { control, setValue } = useForm<IntegrationModalInputs>({
|
||||
defaultValues: { base: defaultValue },
|
||||
});
|
||||
return (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
isLoading={isLoading}
|
||||
fetchTable={mockFetchTable} // The spy
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue} // Actual RHF setValue
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return render(<Component />);
|
||||
};
|
||||
|
||||
describe("BaseSelectDropdown", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the label and select trigger", () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_base")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("base-select")).toBeInTheDocument();
|
||||
expect(screen.getByText("SelectValueMock")).toBeInTheDocument(); // From mocked SelectValue
|
||||
});
|
||||
|
||||
test("renders options from airtableArray", () => {
|
||||
renderComponent();
|
||||
const select = screen.getByTestId("base-select");
|
||||
expect(select.querySelectorAll("option")).toHaveLength(mockAirtableArray.length);
|
||||
expect(screen.getByText("Base One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Base Two")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("disables the select when isLoading is true", () => {
|
||||
renderComponent(true);
|
||||
expect(screen.getByTestId("base-select")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables the select when isLoading is false", () => {
|
||||
renderComponent(false);
|
||||
expect(screen.getByTestId("base-select")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("renders correctly with empty airtableArray", () => {
|
||||
renderComponent(false, undefined, []);
|
||||
const select = screen.getByTestId("base-select");
|
||||
expect(select.querySelectorAll("option")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
|
||||
import { authorize, fetchTables } from "./airtable";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const baseId = "test-base-id";
|
||||
const apiHost = "http://localhost:3000";
|
||||
|
||||
describe("Airtable Library", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchTables", () => {
|
||||
test("should fetch tables successfully", async () => {
|
||||
const mockTables: TIntegrationAirtableTables = {
|
||||
tables: [
|
||||
{ id: "tbl1", name: "Table 1" },
|
||||
{ id: "tbl2", name: "Table 2" },
|
||||
],
|
||||
};
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({ data: mockTables }),
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
const tables = await fetchTables(environmentId, baseId);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
cache: "no-store",
|
||||
});
|
||||
expect(tables).toEqual(mockTables);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorize", () => {
|
||||
test("should return authUrl successfully", async () => {
|
||||
const mockAuthUrl = "https://airtable.com/oauth2/v1/authorize?...";
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: async () => ({ data: { authUrl: mockAuthUrl } }),
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
});
|
||||
expect(authUrl).toBe(mockAuthUrl);
|
||||
});
|
||||
|
||||
test("should throw error and log when fetch fails", async () => {
|
||||
const errorText = "Failed to fetch";
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
text: async () => errorText,
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable, TIntegrationAirtableCredential } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({
|
||||
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys");
|
||||
vi.mock("@/lib/airtable/service");
|
||||
|
||||
let mockAirtableClientId: string | undefined = "test-client-id";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get AIRTABLE_CLIENT_ID() {
|
||||
return mockAirtableClientId;
|
||||
},
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
IS_PRODUCTION: true,
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service");
|
||||
vi.mock("@/lib/utils/locale");
|
||||
vi.mock("@/modules/environments/lib/utils");
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(() => <div>GoBackButton Mock</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation");
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockSurveys: TSurvey[] = [{ id: "survey1", name: "Survey 1" } as TSurvey];
|
||||
const mockAirtableIntegration: TIntegrationAirtable = {
|
||||
type: "airtable",
|
||||
config: {
|
||||
key: { access_token: "test-token" } as unknown as TIntegrationAirtableCredential,
|
||||
data: [],
|
||||
email: "test@example.com",
|
||||
},
|
||||
environmentId: mockEnvironmentId,
|
||||
id: "int_airtable_123",
|
||||
};
|
||||
const mockAirtableTables: TIntegrationItem[] = [{ id: "table1", name: "Table 1" } as TIntegrationItem];
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const props = {
|
||||
params: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
};
|
||||
|
||||
describe("Airtable Integration Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([mockAirtableIntegration]);
|
||||
vi.mocked(getAirtableTables).mockResolvedValue(mockAirtableTables);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects if user is readOnly", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
await render(await Page(props));
|
||||
expect(redirect).toHaveBeenCalledWith("./");
|
||||
});
|
||||
|
||||
test("renders correctly when integration is configured", async () => {
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("GoBackButton Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getSurveys)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getIntegrations)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(getAirtableTables)).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(vi.mocked(findMatchingLocale)).toHaveBeenCalled();
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
{
|
||||
isEnabled: true,
|
||||
airtableIntegration: mockAirtableIntegration,
|
||||
airtableArray: mockAirtableTables,
|
||||
environmentId: mockEnvironmentId,
|
||||
surveys: mockSurveys,
|
||||
environment: mockEnvironment,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
locale: mockLocale,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("renders correctly when integration exists but is not configured (no key)", async () => {
|
||||
const integrationWithoutKey = {
|
||||
...mockAirtableIntegration,
|
||||
config: { ...mockAirtableIntegration.config, key: undefined },
|
||||
} as unknown as TIntegrationAirtable;
|
||||
vi.mocked(getIntegrations).mockResolvedValue([integrationWithoutKey]);
|
||||
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(getAirtableTables)).not.toHaveBeenCalled(); // Should not fetch tables if no key
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
// Update assertion to match the actual call
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
{
|
||||
isEnabled: true, // isEnabled is true because AIRTABLE_CLIENT_ID is set in beforeEach
|
||||
airtableIntegration: integrationWithoutKey,
|
||||
airtableArray: [], // Should be empty as getAirtableTables is not called
|
||||
environmentId: mockEnvironmentId,
|
||||
surveys: mockSurveys,
|
||||
environment: mockEnvironment,
|
||||
webAppUrl: WEBAPP_URL,
|
||||
locale: mockLocale,
|
||||
},
|
||||
undefined // Change second argument to undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("renders correctly when integration is disabled (no client ID)", async () => {
|
||||
mockAirtableClientId = undefined; // Simulate disabled integration
|
||||
|
||||
await render(await Page(props));
|
||||
|
||||
expect(screen.getByText("environments.integrations.airtable.airtable_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("AirtableWrapper Mock")).toBeInTheDocument();
|
||||
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
expect(AirtableWrapper).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isEnabled: false, // Should be false
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock actions and utilities
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({
|
||||
getSpreadsheetNameByIdAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({
|
||||
constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`,
|
||||
extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5],
|
||||
isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
AdditionalIntegrationSettings: ({
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
setIncludeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}: any) => (
|
||||
<div>
|
||||
<span>Additional Settings</span>
|
||||
<input
|
||||
data-testid="include-variables"
|
||||
type="checkbox"
|
||||
checked={includeVariables}
|
||||
onChange={(e) => setIncludeVariables(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-hidden-fields"
|
||||
type="checkbox"
|
||||
checked={includeHiddenFields}
|
||||
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-metadata"
|
||||
type="checkbox"
|
||||
checked={includeMetadata}
|
||||
onChange={(e) => setIncludeMetadata(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-created-at"
|
||||
type="checkbox"
|
||||
checked={includeCreatedAt}
|
||||
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
DropdownSelector: ({ label, items, selectedItem, setSelectedItem }: any) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid="survey-dropdown"
|
||||
value={selectedItem?.id || ""}
|
||||
onChange={(e) => {
|
||||
const selected = items.find((item: any) => item.id === e.target.value);
|
||||
setSelectedItem(selected);
|
||||
}}>
|
||||
<option value="">Select a survey</option>
|
||||
{items.map((item: any) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
||||
}));
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
handleSubmit: (callback: any) => (event: any) => {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@tolgee/react", async () => {
|
||||
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const useTranslate = () => ({
|
||||
t: (key: string, _?: any) => {
|
||||
// NOSONAR
|
||||
// Simple mock translation function
|
||||
if (key === "common.all_questions") return "All questions";
|
||||
if (key === "common.selected_questions") return "Selected questions";
|
||||
if (key === "environments.integrations.google_sheets.link_google_sheet") return "Link Google Sheet";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
if (key === "environments.integrations.google_sheets.spreadsheet_url") return "Spreadsheet URL";
|
||||
if (key === "common.select_survey") return "Select survey";
|
||||
if (key === "common.questions") return "Questions";
|
||||
if (key === "environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error")
|
||||
return "Please enter a valid Google Sheet URL.";
|
||||
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
|
||||
if (key === "environments.integrations.select_at_least_one_question_error")
|
||||
return "Please select at least one question.";
|
||||
if (key === "environments.integrations.integration_updated_successfully")
|
||||
return "Integration updated successfully.";
|
||||
if (key === "environments.integrations.integration_added_successfully")
|
||||
return "Integration added successfully.";
|
||||
if (key === "environments.integrations.integration_removed_successfully")
|
||||
return "Integration removed successfully.";
|
||||
if (key === "environments.integrations.google_sheets.google_sheet_logo") return "Google Sheet logo";
|
||||
if (key === "environments.integrations.google_sheets.google_sheets_integration_description")
|
||||
return "Sync responses with Google Sheets.";
|
||||
if (key === "environments.integrations.create_survey_warning")
|
||||
return "You need to create a survey first.";
|
||||
return key; // Return key if no translation is found
|
||||
},
|
||||
});
|
||||
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
|
||||
.createOrUpdateIntegrationAction
|
||||
);
|
||||
const getSpreadsheetNameByIdAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"))
|
||||
.getSpreadsheetNameByIdAction
|
||||
);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const surveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 2?" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Choice 1" } },
|
||||
{ id: "c2", label: { default: "Choice 2" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 2",
|
||||
type: "link",
|
||||
environmentId: environmentId,
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate this?" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockGoogleSheetIntegration = {
|
||||
id: "integration1",
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
key: {
|
||||
access_token: "mock_access_token",
|
||||
expiry_date: Date.now() + 3600000,
|
||||
refresh_token: "mock_refresh_token",
|
||||
scope: "mock_scope",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
email: "test@example.com",
|
||||
data: [], // Initially empty, will be populated in beforeEach
|
||||
},
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
|
||||
const mockSelectedIntegration: TIntegrationGoogleSheetsConfigData & { index: number } = {
|
||||
spreadsheetId: "existing-sheet-id",
|
||||
spreadsheetName: "Existing Sheet",
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
questionIds: [surveys[0].questions[0].id],
|
||||
questions: "Selected questions",
|
||||
createdAt: new Date(),
|
||||
includeVariables: true,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: false,
|
||||
index: 0,
|
||||
};
|
||||
|
||||
describe("AddIntegrationModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset integration data before each test if needed
|
||||
mockGoogleSheetIntegration.config.data = [
|
||||
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
|
||||
];
|
||||
});
|
||||
|
||||
test("renders correctly when open (create mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toBeInTheDocument();
|
||||
// Use getByTestId for the dropdown
|
||||
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Link Google Sheet" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly when open (update mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toHaveValue("https://docs.google.com/spreadsheets/d/existing-sheet-id");
|
||||
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("include-variables")).toBeChecked();
|
||||
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
|
||||
expect(screen.getByTestId("include-metadata")).toBeChecked();
|
||||
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("selects survey and shows questions", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
|
||||
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
surveys[1].questions.forEach((q) => {
|
||||
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
|
||||
// Initially all questions should be checked when a survey is selected in create mode
|
||||
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles question selection", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
|
||||
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
|
||||
|
||||
await userEvent.click(firstQuestionCheckbox);
|
||||
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
|
||||
|
||||
await userEvent.click(firstQuestionCheckbox);
|
||||
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
|
||||
});
|
||||
|
||||
test("creates integration successfully", async () => {
|
||||
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Test Sheet Name" });
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any }); // Mock successful action
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={{
|
||||
...mockGoogleSheetIntegration,
|
||||
config: { ...mockGoogleSheetIntegration.config, data: [] },
|
||||
}} // Start with empty data
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/new-sheet-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Wait for questions to appear and potentially uncheck one
|
||||
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
|
||||
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
|
||||
|
||||
// Check additional settings
|
||||
await userEvent.click(screen.getByTestId("include-variables"));
|
||||
await userEvent.click(screen.getByTestId("include-metadata"));
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSpreadsheetNameByIdAction).toHaveBeenCalledWith({
|
||||
googleSheetIntegration: expect.any(Object),
|
||||
environmentId,
|
||||
spreadsheetId: "new-sheet-id",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
type: "googleSheets",
|
||||
config: expect.objectContaining({
|
||||
key: mockGoogleSheetIntegration.config.key,
|
||||
email: mockGoogleSheetIntegration.config.email,
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
spreadsheetId: "new-sheet-id",
|
||||
spreadsheetName: "Test Sheet Name",
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
|
||||
questions: "Selected questions",
|
||||
includeVariables: true,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: true, // Default
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes integration successfully", async () => {
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration} // Contains initial data at index 0
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByText("Delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
data: [], // Data array should be empty after deletion
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error for invalid URL", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "invalid-url");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please enter a valid Google Sheet URL.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows validation error if no survey selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
|
||||
// No survey selected
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows validation error if no questions selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/some-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Uncheck all questions
|
||||
for (const question of surveys[0].questions) {
|
||||
const checkbox = await screen.findByLabelText(question.headline.default);
|
||||
await userEvent.click(checkbox);
|
||||
}
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationAction).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
|
||||
const errorMessage = "Failed to update integration";
|
||||
getSpreadsheetNameByIdAction.mockResolvedValue({ data: "Some Sheet Name" });
|
||||
createOrUpdateIntegrationAction.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Google Sheet" });
|
||||
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/another-id");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSpreadsheetNameByIdAction).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls setOpen(false) and resets form on cancel", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText for the input
|
||||
const urlInput = screen.getByPlaceholderText(
|
||||
"https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>"
|
||||
);
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
|
||||
// Simulate some interaction
|
||||
await userEvent.type(urlInput, "https://docs.google.com/spreadsheets/d/temp-id");
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
// Re-render with open=true to check if state was reset (URL should be empty)
|
||||
cleanup();
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
// Use getByPlaceholderText for the input check after re-render
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsCredential,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock child components and functions
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration",
|
||||
() => ({
|
||||
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
|
||||
</div>
|
||||
)),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: vi.fn(({ handleAuthorization }) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization}>Connect</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal",
|
||||
() => ({
|
||||
AddIntegrationModal: vi.fn(({ open }) =>
|
||||
open ? <div data-testid="add-integration-modal">Modal</div> : null
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({
|
||||
authorize: vi.fn(() => Promise.resolve("http://google.com/auth")),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [];
|
||||
const mockWebAppUrl = "http://localhost:3000";
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const mockGoogleSheetIntegration = {
|
||||
id: "test-integration-id",
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
key: { access_token: "test-token" } as unknown as TIntegrationGoogleSheetsCredential,
|
||||
data: [],
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
|
||||
describe("GoogleSheetWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected", () => {
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
// No googleSheetIntegration provided initially
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when integration exists but has no key", () => {
|
||||
const integrationWithoutKey = {
|
||||
...mockGoogleSheetIntegration,
|
||||
config: { data: [], email: "test" },
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={integrationWithoutKey}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls authorize when connect button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Mock window.location.replace
|
||||
const originalLocation = window.location;
|
||||
// @ts-expect-error
|
||||
delete window.location;
|
||||
window.location = { ...originalLocation, replace: vi.fn() } as any;
|
||||
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await user.click(connectButton);
|
||||
|
||||
expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
|
||||
// Need to wait for the promise returned by authorize to resolve
|
||||
await vi.waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth");
|
||||
});
|
||||
|
||||
// Restore window.location
|
||||
window.location = originalLocation as any;
|
||||
});
|
||||
|
||||
test("renders ManageIntegration and AddIntegrationModal when connected", () => {
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
// Modal is rendered but initially hidden
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens AddIntegrationModal when triggered from ManageIntegration", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={true}
|
||||
environment={mockEnvironment}
|
||||
surveys={mockSurveys}
|
||||
googleSheetIntegration={mockGoogleSheetIntegration}
|
||||
webAppUrl={mockWebAppUrl}
|
||||
locale={mockLocale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-integration-modal")).not.toBeInTheDocument();
|
||||
const openModalButton = screen.getByRole("button", { name: "Open Modal" }); // Button inside mocked ManageIntegration
|
||||
await user.click(openModalButton);
|
||||
expect(screen.getByTestId("add-integration-modal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { authorize } from "./google";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
describe("authorize", () => {
|
||||
const environmentId = "test-env-id";
|
||||
const apiHost = "http://test.com";
|
||||
const expectedUrl = `${apiHost}/api/google-sheet`;
|
||||
const expectedHeaders = { environmentId: environmentId };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return authUrl on successful fetch", async () => {
|
||||
const mockAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth?...";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { authUrl: mockAuthUrl } }),
|
||||
});
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
expect(authUrl).toBe(mockAuthUrl);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw error and log on failed fetch", async () => {
|
||||
const errorText = "Failed to fetch";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
text: async () => errorText,
|
||||
});
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ errorText },
|
||||
"authorize: Could not fetch google sheet config"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { constructGoogleSheetsUrl, extractSpreadsheetIdFromUrl, isValidGoogleSheetsUrl } from "./util";
|
||||
|
||||
describe("Google Sheets Util", () => {
|
||||
describe("extractSpreadsheetIdFromUrl", () => {
|
||||
test("should extract spreadsheet ID from a valid URL", () => {
|
||||
const url =
|
||||
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
|
||||
const expectedId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
|
||||
expect(extractSpreadsheetIdFromUrl(url)).toBe(expectedId);
|
||||
});
|
||||
|
||||
test("should throw an error for an invalid URL", () => {
|
||||
const invalidUrl = "https://not-a-google-sheet-url.com";
|
||||
expect(() => extractSpreadsheetIdFromUrl(invalidUrl)).toThrow("Invalid Google Sheets URL");
|
||||
});
|
||||
|
||||
test("should throw an error for a URL without an ID", () => {
|
||||
const urlWithoutId = "https://docs.google.com/spreadsheets/d/";
|
||||
expect(() => extractSpreadsheetIdFromUrl(urlWithoutId)).toThrow("Invalid Google Sheets URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("constructGoogleSheetsUrl", () => {
|
||||
test("should construct a valid Google Sheets URL from a spreadsheet ID", () => {
|
||||
const spreadsheetId = "1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
|
||||
const expectedUrl =
|
||||
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq";
|
||||
expect(constructGoogleSheetsUrl(spreadsheetId)).toBe(expectedUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidGoogleSheetsUrl", () => {
|
||||
test("should return true for a valid Google Sheets URL", () => {
|
||||
const validUrl =
|
||||
"https://docs.google.com/spreadsheets/d/1aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkLmNoPq/edit#gid=0";
|
||||
expect(isValidGoogleSheetsUrl(validUrl)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for an invalid URL", () => {
|
||||
const invalidUrl = "https://not-a-google-sheet-url.com";
|
||||
expect(isValidGoogleSheetsUrl(invalidUrl)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for a base Google Sheets URL", () => {
|
||||
const baseUrl = "https://docs.google.com/spreadsheets/d/";
|
||||
expect(isValidGoogleSheetsUrl(baseUrl)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Loading from "./loading";
|
||||
|
||||
// Mock the GoBackButton component
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: () => <div>GoBackButton</div>,
|
||||
}));
|
||||
|
||||
describe("Loading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the loading state correctly", () => {
|
||||
render(<Loading />);
|
||||
|
||||
// Check for GoBackButton mock
|
||||
expect(screen.getByText("GoBackButton")).toBeInTheDocument();
|
||||
|
||||
// Check for the disabled button text
|
||||
expect(screen.getByText("environments.integrations.google_sheets.link_new_sheet")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.google_sheets.link_new_sheet").closest("button")
|
||||
).toHaveClass("pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none");
|
||||
|
||||
// Check for table headers
|
||||
expect(screen.getByText("common.survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.google_sheets.google_sheet_name")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.questions")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
|
||||
|
||||
// Check for placeholder elements (count based on the loop)
|
||||
const placeholders = screen.getAllByRole("generic", { hidden: true }); // Using generic role as divs don't have implicit roles
|
||||
// Calculate expected placeholders: 3 rows * 5 placeholders per row = 15
|
||||
// Plus the button, header divs (4), and the main containers
|
||||
// It's simpler to check if there are *any* pulse animations
|
||||
expect(placeholders.some((el) => el.classList.contains("animate-pulse"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsCredential,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper",
|
||||
() => ({
|
||||
GoogleSheetWrapper: vi.fn(
|
||||
({ isEnabled, environment, surveys, googleSheetIntegration, webAppUrl, locale }) => (
|
||||
<div>
|
||||
<span>Mocked GoogleSheetWrapper</span>
|
||||
<span data-testid="isEnabled">{isEnabled.toString()}</span>
|
||||
<span data-testid="environmentId">{environment.id}</span>
|
||||
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
|
||||
<span data-testid="integrationId">{googleSheetIntegration?.id}</span>
|
||||
<span data-testid="webAppUrl">{webAppUrl}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
let mockGoogleSheetClientId: string | undefined = "test-client-id";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get GOOGLE_SHEETS_CLIENT_ID() {
|
||||
return mockGoogleSheetClientId;
|
||||
},
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
}));
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrations: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
type: "app",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockGoogleSheetIntegration = {
|
||||
id: "integration1",
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
data: [],
|
||||
key: {
|
||||
refresh_token: "refresh",
|
||||
access_token: "access",
|
||||
expiry_date: Date.now() + 3600000,
|
||||
} as unknown as TIntegrationGoogleSheetsCredential,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as unknown as TIntegrationGoogleSheets;
|
||||
|
||||
const mockProps = {
|
||||
params: { environmentId: "test-env-id" },
|
||||
};
|
||||
|
||||
describe("GoogleSheetsIntegrationPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
});
|
||||
|
||||
test("renders the page with GoogleSheetWrapper when enabled and not read-only", async () => {
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(
|
||||
screen.getByText("environments.integrations.google_sheets.google_sheets_integration")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("isEnabled")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
|
||||
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
|
||||
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockGoogleSheetIntegration.id);
|
||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
|
||||
);
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls redirect when user is read-only", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
|
||||
});
|
||||
|
||||
test("passes isEnabled=false to GoogleSheetWrapper when constants are missing", async () => {
|
||||
mockGoogleSheetClientId = undefined;
|
||||
|
||||
const { default: PageWithMissingConstants } = (await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/page"
|
||||
)) as { default: typeof Page };
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([mockGoogleSheetIntegration]);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
|
||||
const PageComponent = await PageWithMissingConstants(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("isEnabled")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
test("handles case where no Google Sheet integration exists", async () => {
|
||||
vi.mocked(getIntegrations).mockResolvedValue([]); // No integrations
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("Mocked GoogleSheetWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { selectSurvey } from "@/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "./surveys";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/survey/cache", () => ({
|
||||
surveyCache: {
|
||||
tag: {
|
||||
byEnvironmentId: vi.fn((environmentId) => `survey_environment_${environmentId}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
selectSurvey: { id: true, name: true, status: true, updatedAt: true }, // Expanded mock based on usage
|
||||
}));
|
||||
vi.mock("@/lib/survey/utils");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
return {
|
||||
...actual,
|
||||
cache: vi.fn((fn) => fn), // Mock reactCache to just return the function
|
||||
};
|
||||
});
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
// Ensure mockPrismaSurveys includes all fields used in selectSurvey mock
|
||||
const mockPrismaSurveys = [
|
||||
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
|
||||
{ id: "survey2", name: "Survey 2", status: "draft", updatedAt: new Date() },
|
||||
];
|
||||
const mockTransformedSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
type: "app", // Changed type to web to match original file
|
||||
environmentId: environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
styling: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
name: "Survey 2",
|
||||
status: "draft",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
styling: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
describe("getSurveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
test("should fetch and transform surveys successfully", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys);
|
||||
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => {
|
||||
const found = mockTransformedSurveys.find((ts) => ts.id === survey.id);
|
||||
if (!found) throw new Error("Survey not found in mock transformed data");
|
||||
// Ensure the returned object matches the TSurvey structure precisely
|
||||
return { ...found } as TSurvey;
|
||||
});
|
||||
|
||||
const surveys = await getSurveys(environmentId);
|
||||
|
||||
expect(surveys).toEqual(mockTransformedSurveys);
|
||||
// Use expect.any(ZId) for the Zod schema validation check
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
status: {
|
||||
not: "completed",
|
||||
},
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledTimes(mockPrismaSurveys.length);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[0]);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurveys[1]);
|
||||
// Check if the inner cache function was called with the correct arguments
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function), // The async function passed to cache
|
||||
[`getSurveys-${environmentId}`], // The cache key
|
||||
{
|
||||
tags: [surveyCache.tag.byEnvironmentId(environmentId)], // Cache tags
|
||||
}
|
||||
);
|
||||
// Remove the assertion for reactCache being called within the test execution
|
||||
// expect(reactCache).toHaveBeenCalled(); // Removed this line
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
// No need to mock cache here again as beforeEach handles it
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
meta: {}, // Added meta property
|
||||
});
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
|
||||
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
|
||||
});
|
||||
|
||||
test("should throw original error on other errors", async () => {
|
||||
// No need to mock cache here again as beforeEach handles it
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(cache).toHaveBeenCalled(); // Ensure cache wrapper was still called
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getWebhookCountBySource } from "./webhook";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache");
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
tag: {
|
||||
byEnvironmentIdAndSource: vi.fn((envId, source) => `webhook_${envId}_${source ?? "all"}`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const sourceZapier = "zapier";
|
||||
|
||||
describe("getWebhookCountBySource", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cache).mockImplementation((fn) => async () => {
|
||||
return fn();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return webhook count for a specific source", async () => {
|
||||
const mockCount = 5;
|
||||
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
|
||||
|
||||
const count = await getWebhookCountBySource(environmentId, sourceZapier);
|
||||
|
||||
expect(count).toBe(mockCount);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[environmentId, expect.any(Object)],
|
||||
[sourceZapier, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
source: sourceZapier,
|
||||
},
|
||||
});
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getWebhookCountBySource-${environmentId}-${sourceZapier}`],
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, sourceZapier)],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("should return total webhook count when source is undefined", async () => {
|
||||
const mockCount = 10;
|
||||
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
|
||||
|
||||
const count = await getWebhookCountBySource(environmentId);
|
||||
|
||||
expect(count).toBe(mockCount);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[environmentId, expect.any(Object)],
|
||||
[undefined, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
source: undefined,
|
||||
},
|
||||
});
|
||||
expect(cache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[`getWebhookCountBySource-${environmentId}-undefined`],
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, undefined)],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should throw original error on other errors", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
|
||||
expect(cache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,606 @@
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionCredential,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock actions and utilities
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
|
||||
}));
|
||||
vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
getQuestionTypes: () => [
|
||||
{ id: TSurveyQuestionTypeEnum.OpenText, label: "Open Text" },
|
||||
{ id: TSurveyQuestionTypeEnum.MultipleChoiceSingle, label: "Multiple Choice Single" },
|
||||
{ id: TSurveyQuestionTypeEnum.Date, label: "Date" },
|
||||
],
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
|
||||
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
|
||||
{loading ? "Loading..." : children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
|
||||
// Ensure the selected item is always available as an option
|
||||
const allOptions = [...items];
|
||||
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
|
||||
// Use a simple object structure consistent with how options are likely used
|
||||
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
|
||||
}
|
||||
// Remove duplicates just in case
|
||||
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<select
|
||||
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
|
||||
onChange={(e) => {
|
||||
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
|
||||
setSelectedItem(selected);
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<option value="">{placeholder || "Select..."}</option>
|
||||
{/* Render options from the potentially augmented list */}
|
||||
{uniqueOptions.map((item: any) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
PlusIcon: () => <span data-testid="plus-icon">+</span>,
|
||||
XIcon: () => <span data-testid="x-icon">x</span>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
||||
}));
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
handleSubmit: (callback: any) => (event: any) => {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@tolgee/react", async () => {
|
||||
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const useTranslate = () => ({
|
||||
t: (key: string, params?: any) => {
|
||||
// NOSONAR
|
||||
// Simple mock translation function
|
||||
if (key === "common.warning") return "Warning";
|
||||
if (key === "common.metadata") return "Metadata";
|
||||
if (key === "common.created_at") return "Created at";
|
||||
if (key === "common.hidden_field") return "Hidden Field";
|
||||
if (key === "environments.integrations.notion.link_notion_database") return "Link Notion Database";
|
||||
if (key === "environments.integrations.notion.sync_responses_with_a_notion_database")
|
||||
return "Sync responses with a Notion database.";
|
||||
if (key === "environments.integrations.notion.select_a_database") return "Select a database";
|
||||
if (key === "common.select_survey") return "Select survey";
|
||||
if (key === "environments.integrations.notion.map_formbricks_fields_to_notion_property")
|
||||
return "Map Formbricks fields to Notion property";
|
||||
if (key === "environments.integrations.notion.select_a_survey_question")
|
||||
return "Select a survey question";
|
||||
if (key === "environments.integrations.notion.select_a_field_to_map") return "Select a field to map";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "environments.integrations.notion.please_select_a_database")
|
||||
return "Please select a database.";
|
||||
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
|
||||
if (key === "environments.integrations.notion.please_select_at_least_one_mapping")
|
||||
return "Please select at least one mapping.";
|
||||
if (key === "environments.integrations.notion.please_resolve_mapping_errors")
|
||||
return "Please resolve mapping errors.";
|
||||
if (key === "environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
||||
return "Please complete mapping fields.";
|
||||
if (key === "environments.integrations.integration_updated_successfully")
|
||||
return "Integration updated successfully.";
|
||||
if (key === "environments.integrations.integration_added_successfully")
|
||||
return "Integration added successfully.";
|
||||
if (key === "environments.integrations.integration_removed_successfully")
|
||||
return "Integration removed successfully.";
|
||||
if (key === "environments.integrations.notion.notion_logo") return "Notion logo";
|
||||
if (key === "environments.integrations.create_survey_warning")
|
||||
return "You need to create a survey first.";
|
||||
if (key === "environments.integrations.notion.create_at_least_one_database_to_setup_this_integration")
|
||||
return "Create at least one database.";
|
||||
if (key === "environments.integrations.notion.duplicate_connection_warning")
|
||||
return "Duplicate connection warning.";
|
||||
if (key === "environments.integrations.notion.que_name_of_type_cant_be_mapped_to")
|
||||
return `Question ${params.que_name} (${params.question_label}) can't be mapped to ${params.col_name} (${params.col_type}). Allowed types: ${params.mapped_type}`;
|
||||
|
||||
return key; // Return key if no translation is found
|
||||
},
|
||||
});
|
||||
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
|
||||
.createOrUpdateIntegrationAction
|
||||
);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const surveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 2?" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Choice 1" } },
|
||||
{ id: "c2", label: { default: "Choice 2" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
variables: [{ id: "var1", name: "Variable 1" }],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 2",
|
||||
type: "link",
|
||||
environmentId: environmentId,
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.Date,
|
||||
headline: { default: "Date Question?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
variables: [],
|
||||
hiddenFields: { enabled: false },
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const databases: TIntegrationNotionDatabase[] = [
|
||||
{
|
||||
id: "db1",
|
||||
name: "Database 1 Title",
|
||||
properties: {
|
||||
prop1: { id: "p1", name: "Title Prop", type: "title" },
|
||||
prop2: { id: "p2", name: "Text Prop", type: "rich_text" },
|
||||
prop3: { id: "p3", name: "Number Prop", type: "number" },
|
||||
prop4: { id: "p4", name: "Date Prop", type: "date" },
|
||||
prop5: { id: "p5", name: "Unsupported Prop", type: "formula" }, // Unsupported
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "db2",
|
||||
name: "Database 2 Title",
|
||||
properties: {
|
||||
propA: { id: "pa", name: "Name", type: "title" },
|
||||
propB: { id: "pb", name: "Email", type: "email" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockNotionIntegration: TIntegrationNotion = {
|
||||
id: "integration1",
|
||||
type: "notion",
|
||||
environmentId: environmentId,
|
||||
config: {
|
||||
key: {
|
||||
access_token: "token",
|
||||
bot_id: "bot",
|
||||
workspace_name: "ws",
|
||||
workspace_icon: "",
|
||||
} as unknown as TIntegrationNotionCredential,
|
||||
data: [], // Initially empty
|
||||
},
|
||||
};
|
||||
|
||||
const mockSelectedIntegration: TIntegrationNotionConfigData & { index: number } = {
|
||||
databaseId: databases[0].id,
|
||||
databaseName: databases[0].name,
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
mapping: [
|
||||
{
|
||||
column: { id: "p1", name: "Title Prop", type: "title" },
|
||||
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
},
|
||||
{
|
||||
column: { id: "p2", name: "Text Prop", type: "rich_text" },
|
||||
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
},
|
||||
],
|
||||
createdAt: new Date(),
|
||||
index: 0,
|
||||
};
|
||||
|
||||
describe("AddIntegrationModal (Notion)", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset integration data before each test if needed
|
||||
mockNotionIntegration.config.data = [
|
||||
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
|
||||
];
|
||||
});
|
||||
|
||||
test("renders correctly when open (create mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Map Formbricks fields to Notion property")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly when open (update mode)", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={mockNotionIntegration}
|
||||
databases={databases}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
|
||||
|
||||
// Check if mapping rows are rendered
|
||||
await waitFor(() => {
|
||||
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
|
||||
const columnDropdowns = screen.getAllByTestId("dropdown-select-a-field-to-map");
|
||||
|
||||
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
|
||||
expect(columnDropdowns).toHaveLength(2);
|
||||
|
||||
// Assert values for the first row
|
||||
expect(questionDropdowns[0]).toHaveValue("q1");
|
||||
expect(columnDropdowns[0]).toHaveValue("p1");
|
||||
|
||||
// Assert values for the second row
|
||||
expect(questionDropdowns[1]).toHaveValue("var1");
|
||||
expect(columnDropdowns[1]).toHaveValue("p2");
|
||||
|
||||
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("selects database and survey, shows mapping", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
|
||||
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
|
||||
|
||||
await userEvent.selectOptions(dbDropdown, databases[0].id);
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-survey-question")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-field-to-map")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adds and removes mapping rows", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
|
||||
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
|
||||
|
||||
await userEvent.selectOptions(dbDropdown, databases[0].id);
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
|
||||
|
||||
const plusButton = screen.getByTestId("plus-icon");
|
||||
await userEvent.click(plusButton);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
|
||||
|
||||
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
|
||||
await userEvent.click(xButton);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("deletes integration successfully", async () => {
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({ data: null as any });
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={mockNotionIntegration} // Contains initial data at index 0
|
||||
databases={databases}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByText("Delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
data: [], // Data array should be empty after deletion
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error if no database selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a database.");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error if no survey selected", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error if no mapping defined", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
await userEvent.selectOptions(screen.getByTestId("dropdown-select-a-database"), databases[0].id);
|
||||
await userEvent.selectOptions(screen.getByTestId("dropdown-select-survey"), surveys[0].id);
|
||||
// Default mapping row is empty
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "environments.integrations.notion.link_database" })
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select at least one mapping.");
|
||||
});
|
||||
});
|
||||
|
||||
test("calls setOpen(false) and resets form on cancel", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const dbDropdown = screen.getByTestId("dropdown-select-a-database");
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
|
||||
await userEvent.selectOptions(dbDropdown, databases[0].id); // Simulate interaction
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
// Re-render with open=true to check if state was reset
|
||||
cleanup();
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
notionIntegration={{
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, data: [] },
|
||||
}}
|
||||
databases={databases}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(""); // Should be reset
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationNotion, TIntegrationNotionCredential } from "@formbricks/types/integration/notion";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { NotionWrapper } from "./NotionWrapper";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration", () => ({
|
||||
ManageIntegration: vi.fn(({ setIsConnected }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setIsConnected(false)}>Disconnect</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: vi.fn(
|
||||
(
|
||||
{ handleAuthorization, isEnabled } // Reverted back to isEnabled
|
||||
) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization} disabled={!isEnabled}>
|
||||
{" "}
|
||||
{/* Reverted back to isEnabled */}
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock library function
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion", () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock image import
|
||||
vi.mock("@/images/notion-logo.svg", () => ({
|
||||
default: "notion-logo-path",
|
||||
}));
|
||||
|
||||
// Mock window.location.replace
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
replace: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const webAppUrl = "https://app.formbricks.com";
|
||||
const environment = { id: environmentId } as TEnvironment;
|
||||
const surveys: TSurvey[] = [];
|
||||
const databases = [];
|
||||
const locale = "en-US" as const;
|
||||
|
||||
const mockNotionIntegration: TIntegrationNotion = {
|
||||
id: "int-notion-123",
|
||||
type: "notion",
|
||||
environmentId: environmentId,
|
||||
config: {
|
||||
key: { access_token: "test-token" } as TIntegrationNotionCredential,
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
environment,
|
||||
surveys,
|
||||
databasesArray: databases, // Renamed databases to databasesArray to match component prop
|
||||
webAppUrl,
|
||||
locale,
|
||||
};
|
||||
|
||||
describe("NotionWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration disabled when enabled is false", () => {
|
||||
// Changed description slightly
|
||||
render(<NotionWrapper {...baseProps} enabled={false} notionIntegration={undefined} />); // Changed isEnabled to enabled
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration enabled when enabled is true and not connected (no integration)", () => {
|
||||
// Changed description slightly
|
||||
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration enabled when enabled is true and not connected (integration without key)", () => {
|
||||
// Changed description slightly
|
||||
const integrationWithoutKey = {
|
||||
...mockNotionIntegration,
|
||||
config: { data: [] },
|
||||
} as unknown as TIntegrationNotion;
|
||||
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={integrationWithoutKey} />); // Changed isEnabled to enabled
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls authorize and redirects when Connect button is clicked", async () => {
|
||||
const mockAuthorize = vi.mocked(authorize);
|
||||
const redirectUrl = "https://notion.com/auth";
|
||||
mockAuthorize.mockResolvedValue(redirectUrl);
|
||||
|
||||
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await userEvent.click(connectButton);
|
||||
|
||||
expect(mockAuthorize).toHaveBeenCalledWith(environmentId, webAppUrl);
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith(redirectUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { authorize } from "./notion";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
describe("authorize", () => {
|
||||
const environmentId = "test-env-id";
|
||||
const apiHost = "http://test.com";
|
||||
const expectedUrl = `${apiHost}/api/v1/integrations/notion`;
|
||||
const expectedHeaders = { environmentId: environmentId };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return authUrl on successful fetch", async () => {
|
||||
const mockAuthUrl = "https://api.notion.com/v1/oauth/authorize?...";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { authUrl: mockAuthUrl } }),
|
||||
});
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
expect(authUrl).toBe(mockAuthUrl);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw error and log on failed fetch", async () => {
|
||||
const errorText = "Failed to fetch";
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
text: async () => errorText,
|
||||
});
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: expectedHeaders,
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch notion config");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Loading from "./loading";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, className }: { children: React.ReactNode; className: string }) => (
|
||||
<button className={className}>{children}</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: () => <div data-testid="go-back-button">Go Back</div>,
|
||||
}));
|
||||
|
||||
// Mock @tolgee/react
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key, // Simple mock translation
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Notion Integration Loading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading state correctly", () => {
|
||||
render(<Loading />);
|
||||
|
||||
// Check for GoBackButton mock
|
||||
expect(screen.getByTestId("go-back-button")).toBeInTheDocument();
|
||||
|
||||
// Check for the disabled button
|
||||
const linkButton = screen.getByText("environments.integrations.notion.link_database");
|
||||
expect(linkButton).toBeInTheDocument();
|
||||
expect(linkButton.closest("button")).toHaveClass(
|
||||
"pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200"
|
||||
);
|
||||
|
||||
// Check for table headers
|
||||
expect(screen.getByText("common.survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion.database_name")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
|
||||
|
||||
// Check for placeholder elements (skeleton loaders)
|
||||
// There should be 3 rows * 5 pulse divs per row = 15 pulse divs
|
||||
const pulseDivs = screen.getAllByText("", { selector: "div.animate-pulse" });
|
||||
expect(pulseDivs.length).toBeGreaterThanOrEqual(15); // Check if at least 15 pulse divs are rendered
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/notion/page";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getNotionDatabases } from "@/lib/notion/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper", () => ({
|
||||
NotionWrapper: vi.fn(
|
||||
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
|
||||
<div>
|
||||
<span>Mocked NotionWrapper</span>
|
||||
<span data-testid="enabled">{enabled.toString()}</span>
|
||||
<span data-testid="environmentId">{environment.id}</span>
|
||||
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
|
||||
<span data-testid="integrationId">{notionIntegration?.id}</span>
|
||||
<span data-testid="webAppUrl">{webAppUrl}</span>
|
||||
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
let mockNotionClientId: string | undefined = "test-client-id";
|
||||
let mockNotionClientSecret: string | undefined = "test-client-secret";
|
||||
let mockNotionAuthUrl: string | undefined = "https://notion.com/auth";
|
||||
let mockNotionRedirectUri: string | undefined = "https://app.formbricks.com/redirect";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get NOTION_OAUTH_CLIENT_ID() {
|
||||
return mockNotionClientId;
|
||||
},
|
||||
get NOTION_OAUTH_CLIENT_SECRET() {
|
||||
return mockNotionClientSecret;
|
||||
},
|
||||
get NOTION_AUTH_URL() {
|
||||
return mockNotionAuthUrl;
|
||||
},
|
||||
get NOTION_REDIRECT_URI() {
|
||||
return mockNotionRedirectUri;
|
||||
},
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrationByType: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/notion/service", () => ({
|
||||
getNotionDatabases: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
type: "app",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockNotionIntegration = {
|
||||
id: "integration1",
|
||||
type: "notion",
|
||||
config: {
|
||||
data: [],
|
||||
key: { bot_id: "bot-id-123" },
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as unknown as TIntegrationNotion;
|
||||
|
||||
const mockDatabases: TIntegrationNotionDatabase[] = [
|
||||
{ id: "db1", name: "Database 1", properties: {} },
|
||||
{ id: "db2", name: "Database 2", properties: {} },
|
||||
];
|
||||
|
||||
const mockProps = {
|
||||
params: { environmentId: "test-env-id" },
|
||||
};
|
||||
|
||||
describe("NotionIntegrationPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
} as TEnvironmentAuth);
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(mockNotionIntegration);
|
||||
vi.mocked(getNotionDatabases).mockResolvedValue(mockDatabases);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
mockNotionClientId = "test-client-id";
|
||||
mockNotionClientSecret = "test-client-secret";
|
||||
mockNotionAuthUrl = "https://notion.com/auth";
|
||||
mockNotionRedirectUri = "https://app.formbricks.com/redirect";
|
||||
});
|
||||
|
||||
test("renders the page with NotionWrapper when enabled and not read-only", async () => {
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("environments.integrations.notion.notion_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
|
||||
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
|
||||
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockNotionIntegration.id);
|
||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
|
||||
);
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
|
||||
});
|
||||
|
||||
test("calls redirect when user is read-only", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
|
||||
});
|
||||
|
||||
test("passes enabled=false to NotionWrapper when constants are missing", async () => {
|
||||
mockNotionClientId = undefined; // Simulate missing constant
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("enabled")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
test("handles case where no Notion integration exists", async () => {
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(null); // No integration
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("integrationId")).toBeEmptyDOMElement(); // No integration ID passed
|
||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
|
||||
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles case where integration exists but has no key (bot_id)", async () => {
|
||||
const integrationWithoutKey = {
|
||||
...mockNotionIntegration,
|
||||
config: { ...mockNotionIntegration.config, key: undefined },
|
||||
} as unknown as TIntegrationNotion;
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(integrationWithoutKey);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("Mocked NotionWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("integrationId")).toHaveTextContent(integrationWithoutKey.id);
|
||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent("0"); // No databases fetched
|
||||
expect(vi.mocked(getNotionDatabases)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/page";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegration } from "@formbricks/types/integration";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/webhook", () => ({
|
||||
getWebhookCountBySource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrations: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/integration-card", () => ({
|
||||
Card: ({ label, description, statusText, disabled }) => (
|
||||
<div data-testid={`card-${label}`}>
|
||||
<h1>{label}</h1>
|
||||
<p>{description}</p>
|
||||
<span>{statusText}</span>
|
||||
{disabled && <span>Disabled</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: ({ pageTitle }) => <h1>{pageTitle}</h1>,
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ alt }) => <img alt={alt} />,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
appSetupCompleted: true,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockIntegrations: TIntegration[] = [
|
||||
{
|
||||
id: "google-sheets-id",
|
||||
type: "googleSheets",
|
||||
environmentId: "test-env-id",
|
||||
config: { data: [], email: "test@example.com" } as unknown as TIntegration["config"],
|
||||
},
|
||||
{
|
||||
id: "slack-id",
|
||||
type: "slack",
|
||||
environmentId: "test-env-id",
|
||||
config: { data: [] } as unknown as TIntegration["config"],
|
||||
},
|
||||
];
|
||||
|
||||
const mockParams = { environmentId: "test-env-id" };
|
||||
const mockProps = { params: mockParams };
|
||||
|
||||
describe("Integrations Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getWebhookCountBySource).mockResolvedValue(0);
|
||||
vi.mocked(getIntegrations).mockResolvedValue([]);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
isBilling: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
});
|
||||
|
||||
test("renders the page header and integration cards", async () => {
|
||||
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
|
||||
if (source === "zapier") return 1;
|
||||
if (source === "user") return 2;
|
||||
return 0;
|
||||
});
|
||||
vi.mocked(getIntegrations).mockResolvedValue(mockIntegrations);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("common.integrations")).toBeInTheDocument(); // Page Header
|
||||
expect(screen.getByTestId("card-Javascript SDK")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.website_or_app_integration_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.connected")[0]).toBeInTheDocument(); // JS SDK status
|
||||
|
||||
expect(screen.getByTestId("card-Zapier")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.zapier_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 zap")).toBeInTheDocument(); // Zapier status
|
||||
|
||||
expect(screen.getByTestId("card-Webhooks")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.webhook_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 webhooks")).toBeInTheDocument(); // Webhook status
|
||||
|
||||
expect(screen.getByTestId("card-Google Sheets")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.google_sheet_integration_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.connected")[1]).toBeInTheDocument(); // Google Sheets status
|
||||
|
||||
expect(screen.getByTestId("card-Airtable")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.airtable_integration_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[0]).toBeInTheDocument(); // Airtable status
|
||||
|
||||
expect(screen.getByTestId("card-Slack")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.slack_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.connected")[2]).toBeInTheDocument(); // Slack status
|
||||
|
||||
expect(screen.getByTestId("card-n8n")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.n8n_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[1]).toBeInTheDocument(); // n8n status
|
||||
|
||||
expect(screen.getByTestId("card-Make.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.make_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[2]).toBeInTheDocument(); // Make status
|
||||
|
||||
expect(screen.getByTestId("card-Notion")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion_integration_description")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[3]).toBeInTheDocument(); // Notion status
|
||||
|
||||
expect(screen.getByTestId("card-Activepieces")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.integrations.activepieces_integration_description")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.not_connected")[4]).toBeInTheDocument(); // Activepieces status
|
||||
});
|
||||
|
||||
test("renders disabled cards when isReadOnly is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: true,
|
||||
isBilling: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
// JS SDK and Webhooks should not be disabled
|
||||
expect(screen.getByTestId("card-Javascript SDK")).not.toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Webhooks")).not.toHaveTextContent("Disabled");
|
||||
|
||||
// Other cards should be disabled
|
||||
expect(screen.getByTestId("card-Zapier")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Google Sheets")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Airtable")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Slack")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-n8n")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Notion")).toHaveTextContent("Disabled");
|
||||
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("Disabled");
|
||||
});
|
||||
|
||||
test("redirects when isBilling is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
isReadOnly: false,
|
||||
isBilling: true,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
|
||||
await Page(mockProps);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith(
|
||||
`/environments/${mockParams.environmentId}/settings/billing`
|
||||
);
|
||||
});
|
||||
|
||||
test("renders correct status text for single integration", async () => {
|
||||
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
|
||||
if (source === "n8n") return 1;
|
||||
if (source === "make") return 1;
|
||||
if (source === "activepieces") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("card-n8n")).toHaveTextContent("1 common.integration");
|
||||
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("1 common.integration");
|
||||
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("1 common.integration");
|
||||
});
|
||||
|
||||
test("renders correct status text for multiple integrations", async () => {
|
||||
vi.mocked(getWebhookCountBySource).mockImplementation(async (envId, source) => {
|
||||
if (source === "n8n") return 3;
|
||||
if (source === "make") return 4;
|
||||
if (source === "activepieces") return 5;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("card-n8n")).toHaveTextContent("3 common.integrations");
|
||||
expect(screen.getByTestId("card-Make.com")).toHaveTextContent("4 common.integrations");
|
||||
expect(screen.getByTestId("card-Activepieces")).toHaveTextContent("5 common.integrations");
|
||||
});
|
||||
|
||||
test("renders not connected status when widgetSetupCompleted is false", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: { ...mockEnvironment, appSetupCompleted: false },
|
||||
isReadOnly: false,
|
||||
isBilling: false,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByTestId("card-Javascript SDK")).toHaveTextContent("common.not_connected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,750 @@
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationSlack,
|
||||
TIntegrationSlackConfigData,
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { AddChannelMappingModal } from "./AddChannelMappingModal";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
AdditionalIntegrationSettings: ({
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
setIncludeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}: any) => (
|
||||
<div>
|
||||
<span>Additional Settings</span>
|
||||
<input
|
||||
data-testid="include-variables"
|
||||
type="checkbox"
|
||||
checked={includeVariables}
|
||||
onChange={(e) => setIncludeVariables(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-hidden-fields"
|
||||
type="checkbox"
|
||||
checked={includeHiddenFields}
|
||||
onChange={(e) => setIncludeHiddenFields(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-metadata"
|
||||
type="checkbox"
|
||||
checked={includeMetadata}
|
||||
onChange={(e) => setIncludeMetadata(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
data-testid="include-created-at"
|
||||
type="checkbox"
|
||||
checked={includeCreatedAt}
|
||||
onChange={(e) => setIncludeCreatedAt(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, disabled }: any) => (
|
||||
<div>
|
||||
<label>{label}</label>
|
||||
<select
|
||||
data-testid={label.includes("channel") ? "channel-dropdown" : "survey-dropdown"}
|
||||
value={selectedItem?.id || ""}
|
||||
onChange={(e) => {
|
||||
const selected = items.find((item: any) => item.id === e.target.value);
|
||||
setSelectedItem(selected);
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<option value="">Select...</option>
|
||||
{items.map((item: any) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
||||
}));
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...props }: any) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
handleSubmit: (callback: any) => (event: any) => {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@tolgee/react", async () => {
|
||||
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const useTranslate = () => ({
|
||||
t: (key: string, _?: any) => {
|
||||
// NOSONAR
|
||||
// Simple mock translation function
|
||||
if (key === "common.all_questions") return "All questions";
|
||||
if (key === "common.selected_questions") return "Selected questions";
|
||||
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
if (key === "environments.integrations.slack.select_channel") return "Select channel";
|
||||
if (key === "common.select_survey") return "Select survey";
|
||||
if (key === "common.questions") return "Questions";
|
||||
if (key === "environments.integrations.slack.please_select_a_channel")
|
||||
return "Please select a channel.";
|
||||
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
|
||||
if (key === "environments.integrations.select_at_least_one_question_error")
|
||||
return "Please select at least one question.";
|
||||
if (key === "environments.integrations.integration_updated_successfully")
|
||||
return "Integration updated successfully.";
|
||||
if (key === "environments.integrations.integration_added_successfully")
|
||||
return "Integration added successfully.";
|
||||
if (key === "environments.integrations.integration_removed_successfully")
|
||||
return "Integration removed successfully.";
|
||||
if (key === "environments.integrations.slack.dont_see_your_channel") return "Don't see your channel?";
|
||||
if (key === "common.note") return "Note";
|
||||
if (key === "environments.integrations.slack.already_connected_another_survey")
|
||||
return "This channel is already connected to another survey.";
|
||||
if (key === "environments.integrations.slack.create_at_least_one_channel_error")
|
||||
return "Please create at least one channel in Slack first.";
|
||||
if (key === "environments.integrations.create_survey_warning")
|
||||
return "You need to create a survey first.";
|
||||
if (key === "environments.integrations.slack.link_channel") return "Link Channel";
|
||||
return key; // Return key if no translation is found
|
||||
},
|
||||
});
|
||||
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
|
||||
});
|
||||
vi.mock("lucide-react", () => ({
|
||||
CircleHelpIcon: () => <div data-testid="circle-help-icon" />,
|
||||
Check: () => <div data-testid="check-icon" />, // Add the Check icon mock
|
||||
Loader2: () => <div data-testid="loader-icon" />, // Add the Loader2 icon mock
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationActionMock = vi.mocked(createOrUpdateIntegrationAction);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const surveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Question 2?" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Choice 1" } },
|
||||
{ id: "c2", label: { default: "Choice 2" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 2",
|
||||
type: "link",
|
||||
environmentId: environmentId,
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate this?" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const channels: TIntegrationItem[] = [
|
||||
{ id: "channel1", name: "#general" },
|
||||
{ id: "channel2", name: "#random" },
|
||||
];
|
||||
|
||||
const mockSlackIntegration: TIntegrationSlack = {
|
||||
id: "integration1",
|
||||
type: "slack",
|
||||
environmentId: environmentId,
|
||||
config: {
|
||||
key: {
|
||||
access_token: "xoxb-test-token",
|
||||
team_name: "Test Team",
|
||||
team_id: "T123",
|
||||
} as unknown as TIntegrationSlackCredential,
|
||||
data: [], // Initially empty
|
||||
},
|
||||
};
|
||||
|
||||
const mockSelectedIntegration: TIntegrationSlackConfigData & { index: number } = {
|
||||
channelId: channels[0].id,
|
||||
channelName: channels[0].name,
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
questionIds: [surveys[0].questions[0].id],
|
||||
questions: "Selected questions",
|
||||
createdAt: new Date(),
|
||||
includeVariables: true,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: false,
|
||||
index: 0,
|
||||
};
|
||||
|
||||
describe("AddChannelMappingModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset integration data before each test if needed
|
||||
mockSlackIntegration.config.data = [
|
||||
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
|
||||
];
|
||||
});
|
||||
|
||||
test("renders correctly when open (create mode)", () => {
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Link Channel" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Questions")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Don't see your channel?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly when open (update mode)", () => {
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
|
||||
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("include-variables")).toBeChecked();
|
||||
expect(screen.getByTestId("include-hidden-fields")).not.toBeChecked();
|
||||
expect(screen.getByTestId("include-metadata")).toBeChecked();
|
||||
expect(screen.getByTestId("include-created-at")).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("selects survey and shows questions", async () => {
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[1].id);
|
||||
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
surveys[1].questions.forEach((q) => {
|
||||
expect(screen.getByLabelText(q.headline.default)).toBeInTheDocument();
|
||||
// Initially all questions should be checked when a survey is selected in create mode
|
||||
expect(screen.getByLabelText(q.headline.default)).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles question selection", async () => {
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
const firstQuestionCheckbox = screen.getByLabelText(surveys[0].questions[0].headline.default);
|
||||
expect(firstQuestionCheckbox).toBeChecked(); // Initially checked
|
||||
|
||||
await userEvent.click(firstQuestionCheckbox);
|
||||
expect(firstQuestionCheckbox).not.toBeChecked(); // Unchecked after click
|
||||
|
||||
await userEvent.click(firstQuestionCheckbox);
|
||||
expect(firstQuestionCheckbox).toBeChecked(); // Checked again
|
||||
});
|
||||
|
||||
test("creates integration successfully", async () => {
|
||||
createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any }); // Mock successful action
|
||||
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={{ ...mockSlackIntegration, config: { ...mockSlackIntegration.config, data: [] } }} // Start with empty data
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const channelDropdown = screen.getByTestId("channel-dropdown");
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Channel" });
|
||||
|
||||
await userEvent.selectOptions(channelDropdown, channels[1].id);
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Wait for questions to appear and potentially uncheck one
|
||||
const firstQuestionCheckbox = await screen.findByLabelText(surveys[0].questions[0].headline.default);
|
||||
await userEvent.click(firstQuestionCheckbox); // Uncheck first question
|
||||
|
||||
// Check additional settings
|
||||
await userEvent.click(screen.getByTestId("include-variables"));
|
||||
await userEvent.click(screen.getByTestId("include-metadata"));
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
type: "slack",
|
||||
config: expect.objectContaining({
|
||||
key: mockSlackIntegration.config.key,
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
channelId: channels[1].id,
|
||||
channelName: channels[1].name,
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
questionIds: surveys[0].questions.slice(1).map((q) => q.id), // Excludes the first question
|
||||
questions: "Selected questions",
|
||||
includeVariables: true,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: true, // Default
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration added successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes integration successfully", async () => {
|
||||
createOrUpdateIntegrationActionMock.mockResolvedValue({ data: null as any });
|
||||
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration} // Contains initial data at index 0
|
||||
channels={channels}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByText("Delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
integrationData: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
data: [], // Data array should be empty after deletion
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows validation error if no channel selected", async () => {
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Channel" });
|
||||
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
// No channel selected
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a channel.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows validation error if no survey selected", async () => {
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const channelDropdown = screen.getByTestId("channel-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Channel" });
|
||||
|
||||
await userEvent.selectOptions(channelDropdown, channels[0].id);
|
||||
// No survey selected
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows validation error if no questions selected", async () => {
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const channelDropdown = screen.getByTestId("channel-dropdown");
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Channel" });
|
||||
|
||||
await userEvent.selectOptions(channelDropdown, channels[0].id);
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Uncheck all questions
|
||||
for (const question of surveys[0].questions) {
|
||||
const checkbox = await screen.findByLabelText(question.headline.default);
|
||||
await userEvent.click(checkbox);
|
||||
}
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select at least one question.");
|
||||
});
|
||||
expect(createOrUpdateIntegrationActionMock).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast if createOrUpdateIntegrationAction fails", async () => {
|
||||
const errorMessage = "Failed to update integration";
|
||||
createOrUpdateIntegrationActionMock.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const channelDropdown = screen.getByTestId("channel-dropdown");
|
||||
const surveyDropdown = screen.getByTestId("survey-dropdown");
|
||||
const submitButton = screen.getByRole("button", { name: "Link Channel" });
|
||||
|
||||
await userEvent.selectOptions(channelDropdown, channels[0].id);
|
||||
await userEvent.selectOptions(surveyDropdown, surveys[0].id);
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationActionMock).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
expect(mockSetOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls setOpen(false) and resets form on cancel", async () => {
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const channelDropdown = screen.getByTestId("channel-dropdown");
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
|
||||
// Simulate some interaction
|
||||
await userEvent.selectOptions(channelDropdown, channels[0].id);
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
// Re-render with open=true to check if state was reset (channel should be unselected)
|
||||
cleanup();
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={mockSlackIntegration}
|
||||
channels={channels}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("channel-dropdown")).toHaveValue("");
|
||||
});
|
||||
|
||||
test("shows warning when selected channel is already connected (add mode)", async () => {
|
||||
// Add an existing connection for channel1
|
||||
const integrationWithExisting = {
|
||||
...mockSlackIntegration,
|
||||
config: {
|
||||
...mockSlackIntegration.config,
|
||||
data: [
|
||||
{
|
||||
channelId: "channel1",
|
||||
channelName: "#general",
|
||||
surveyId: "survey-other",
|
||||
surveyName: "Other Survey",
|
||||
questionIds: ["q-other"],
|
||||
questions: "All questions",
|
||||
createdAt: new Date(),
|
||||
} as TIntegrationSlackConfigData,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={integrationWithExisting}
|
||||
channels={channels}
|
||||
selectedIntegration={null} // Add mode
|
||||
/>
|
||||
);
|
||||
|
||||
const channelDropdown = screen.getByTestId("channel-dropdown");
|
||||
await userEvent.selectOptions(channelDropdown, "channel1");
|
||||
|
||||
expect(screen.getByText("This channel is already connected to another survey.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not show warning when selected channel is the one being edited", async () => {
|
||||
// Edit the existing connection for channel1
|
||||
const integrationToEdit = {
|
||||
...mockSlackIntegration,
|
||||
config: {
|
||||
...mockSlackIntegration.config,
|
||||
data: [
|
||||
{
|
||||
channelId: "channel1",
|
||||
channelName: "#general",
|
||||
surveyId: "survey1",
|
||||
surveyName: "Survey 1",
|
||||
questionIds: ["q1"],
|
||||
questions: "Selected questions",
|
||||
createdAt: new Date(),
|
||||
index: 0,
|
||||
} as TIntegrationSlackConfigData & { index: number },
|
||||
],
|
||||
},
|
||||
};
|
||||
const selectedIntegrationForEdit = integrationToEdit.config.data[0];
|
||||
|
||||
render(
|
||||
<AddChannelMappingModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
slackIntegration={integrationToEdit}
|
||||
channels={channels}
|
||||
selectedIntegration={selectedIntegrationForEdit} // Edit mode
|
||||
/>
|
||||
);
|
||||
|
||||
const channelDropdown = screen.getByTestId("channel-dropdown");
|
||||
// Channel is already selected via selectedIntegration prop
|
||||
expect(channelDropdown).toHaveValue("channel1");
|
||||
|
||||
expect(
|
||||
screen.queryByText("This channel is already connected to another survey.")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
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 { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getSlackChannelsAction } from "../actions";
|
||||
import { authorize } from "../lib/slack";
|
||||
import { SlackWrapper } from "./SlackWrapper";
|
||||
|
||||
// Mock child components and actions
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/actions", () => ({
|
||||
getSlackChannelsAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/slack/components/AddChannelMappingModal",
|
||||
() => ({
|
||||
AddChannelMappingModal: vi.fn(({ open }) => (open ? <div data-testid="add-modal">Add Modal</div> : null)),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/ManageIntegration", () => ({
|
||||
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal, setIsConnected, handleSlackAuthorization }) => (
|
||||
<div data-testid="manage-integration">
|
||||
<button onClick={() => setOpenAddIntegrationModal(true)}>Open Modal</button>
|
||||
<button onClick={() => setIsConnected(false)}>Disconnect</button>
|
||||
<button onClick={handleSlackAuthorization}>Reconnect</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/lib/slack", () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/images/slacklogo.png", () => ({
|
||||
default: "slack-logo-path",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: vi.fn(({ handleAuthorization, isEnabled }) => (
|
||||
<div data-testid="connect-integration">
|
||||
<button onClick={handleAuthorization} disabled={!isEnabled}>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock window.location.replace
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
replace: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockEnvironment = { id: "test-env-id" } as TEnvironment;
|
||||
const mockSurveys: TSurvey[] = [];
|
||||
const mockWebAppUrl = "http://localhost:3000";
|
||||
const mockLocale: TUserLocale = "en-US";
|
||||
const mockSlackChannels: TIntegrationItem[] = [{ id: "C123", name: "general" }];
|
||||
|
||||
const mockSlackIntegration: TIntegrationSlack = {
|
||||
id: "slack-int-1",
|
||||
type: "slack",
|
||||
environmentId: "test-env-id",
|
||||
config: {
|
||||
key: { access_token: "xoxb-valid-token" } as unknown as TIntegrationSlackCredential,
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
const baseProps = {
|
||||
environment: mockEnvironment,
|
||||
surveys: mockSurveys,
|
||||
webAppUrl: mockWebAppUrl,
|
||||
locale: mockLocale,
|
||||
};
|
||||
|
||||
describe("SlackWrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSlackChannelsAction).mockResolvedValue({ data: mockSlackChannels });
|
||||
vi.mocked(authorize).mockResolvedValue("https://slack.com/auth");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected (no integration)", () => {
|
||||
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={undefined} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected (integration without key)", () => {
|
||||
const integrationWithoutKey = { ...mockSlackIntegration, config: { data: [], email: "test" } } as any;
|
||||
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={integrationWithoutKey} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration disabled when isEnabled is false", () => {
|
||||
render(<SlackWrapper {...baseProps} isEnabled={false} slackIntegration={undefined} />);
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("calls authorize and redirects when Connect button is clicked", async () => {
|
||||
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={undefined} />);
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await userEvent.click(connectButton);
|
||||
|
||||
expect(authorize).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith("https://slack.com/auth");
|
||||
});
|
||||
});
|
||||
|
||||
test("renders ManageIntegration and AddChannelMappingModal (hidden) when connected", () => {
|
||||
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-integration")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-modal")).not.toBeInTheDocument(); // Modal is initially hidden
|
||||
});
|
||||
|
||||
test("calls getSlackChannelsAction on mount", async () => {
|
||||
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
|
||||
await waitFor(() => {
|
||||
expect(getSlackChannelsAction).toHaveBeenCalledWith({ environmentId: mockEnvironment.id });
|
||||
});
|
||||
});
|
||||
|
||||
test("switches from ManageIntegration to ConnectIntegration when disconnected", async () => {
|
||||
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
|
||||
expect(screen.getByTestId("manage-integration")).toBeInTheDocument();
|
||||
|
||||
const disconnectButton = screen.getByRole("button", { name: "Disconnect" });
|
||||
await userEvent.click(disconnectButton);
|
||||
|
||||
expect(screen.getByTestId("connect-integration")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("manage-integration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens AddChannelMappingModal when triggered from ManageIntegration", async () => {
|
||||
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
|
||||
expect(screen.queryByTestId("add-modal")).not.toBeInTheDocument();
|
||||
|
||||
const openModalButton = screen.getByRole("button", { name: "Open Modal" });
|
||||
await userEvent.click(openModalButton);
|
||||
|
||||
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls handleSlackAuthorization when reconnect button is clicked in ManageIntegration", async () => {
|
||||
render(<SlackWrapper {...baseProps} isEnabled={true} slackIntegration={mockSlackIntegration} />);
|
||||
const reconnectButton = screen.getByRole("button", { name: "Reconnect" });
|
||||
await userEvent.click(reconnectButton);
|
||||
|
||||
expect(authorize).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith("https://slack.com/auth");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { authorize } from "./slack";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe("authorize", () => {
|
||||
const environmentId = "test-env-id";
|
||||
const apiHost = "http://test.com";
|
||||
const expectedUrl = `${apiHost}/api/v1/integrations/slack`;
|
||||
const expectedAuthUrl = "http://slack.com/auth";
|
||||
|
||||
test("should return authUrl on successful fetch", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { authUrl: expectedAuthUrl } }),
|
||||
} as Response);
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: { environmentId },
|
||||
});
|
||||
expect(authUrl).toBe(expectedAuthUrl);
|
||||
});
|
||||
|
||||
test("should throw error and log error on failed fetch", async () => {
|
||||
const errorText = "Failed to fetch";
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
text: async () => errorText,
|
||||
} as Response);
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
headers: { environmentId },
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch slack config");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/slack/page";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper", () => ({
|
||||
SlackWrapper: vi.fn(({ isEnabled, environment, surveys, slackIntegration, webAppUrl, locale }) => (
|
||||
<div data-testid="slack-wrapper">
|
||||
Mock SlackWrapper: isEnabled={isEnabled.toString()}, envId={environment.id}, surveys=
|
||||
{surveys.length}, integrationId={slackIntegration?.id}, webAppUrl={webAppUrl}, locale={locale}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: true,
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SLACK_CLIENT_ID: "test-slack-client-id",
|
||||
SLACK_CLIENT_SECRET: "test-slack-client-secret",
|
||||
WEBAPP_URL: "http://test.formbricks.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrationByType: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back-button">Go Back: {url}</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1 data-testid="page-header">{pageTitle}</h1>),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const environmentId = "test-env-id";
|
||||
const mockEnvironment = {
|
||||
id: environmentId,
|
||||
createdAt: new Date(),
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: environmentId,
|
||||
status: "inProgress",
|
||||
type: "link",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
languages: [],
|
||||
styling: null,
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
closeOnDate: null,
|
||||
runOnDate: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
const mockSlackIntegration = {
|
||||
id: "slack-int-id",
|
||||
type: "slack",
|
||||
config: {
|
||||
data: [],
|
||||
key: "test-key" as unknown as TIntegrationSlackCredential,
|
||||
},
|
||||
} as unknown as TIntegrationSlack;
|
||||
const mockLocale = "en-US";
|
||||
const mockParams = { params: { environmentId } };
|
||||
|
||||
describe("SlackIntegrationPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(mockSlackIntegration);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
});
|
||||
|
||||
test("renders correctly when user is not read-only", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
environment: mockEnvironment,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
|
||||
const tree = await Page(mockParams);
|
||||
render(tree);
|
||||
|
||||
expect(screen.getByTestId("page-header")).toHaveTextContent(
|
||||
"environments.integrations.slack.slack_integration"
|
||||
);
|
||||
expect(screen.getByTestId("go-back-button")).toHaveTextContent(
|
||||
`Go Back: http://test.formbricks.com/environments/${environmentId}/integrations`
|
||||
);
|
||||
expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument();
|
||||
|
||||
// Check props passed to SlackWrapper
|
||||
expect(vi.mocked(SlackWrapper)).toHaveBeenCalledWith(
|
||||
{
|
||||
isEnabled: true, // Since SLACK_CLIENT_ID and SLACK_CLIENT_SECRET are mocked
|
||||
environment: mockEnvironment,
|
||||
surveys: mockSurveys,
|
||||
slackIntegration: mockSlackIntegration,
|
||||
webAppUrl: "http://test.formbricks.com",
|
||||
locale: mockLocale,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects when user is read-only", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: true,
|
||||
environment: mockEnvironment,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
|
||||
// Need to actually call the component function to trigger the redirect logic
|
||||
await Page(mockParams);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
|
||||
expect(vi.mocked(SlackWrapper)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders correctly when Slack integration is not configured", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
environment: mockEnvironment,
|
||||
} as unknown as TEnvironmentAuth);
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(null); // Simulate no integration found
|
||||
|
||||
const tree = await Page(mockParams);
|
||||
render(tree);
|
||||
|
||||
expect(screen.getByTestId("page-header")).toHaveTextContent(
|
||||
"environments.integrations.slack.slack_integration"
|
||||
);
|
||||
expect(screen.getByTestId("slack-wrapper")).toBeInTheDocument();
|
||||
|
||||
// Check props passed to SlackWrapper when integration is null
|
||||
expect(vi.mocked(SlackWrapper)).toHaveBeenCalledWith(
|
||||
{
|
||||
isEnabled: true,
|
||||
environment: mockEnvironment,
|
||||
surveys: mockSurveys,
|
||||
slackIntegration: null, // Expecting null here
|
||||
webAppUrl: "http://test.formbricks.com",
|
||||
locale: mockLocale,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import WebhooksPage from "./page";
|
||||
|
||||
vi.mock("@/modules/integrations/webhooks/page", () => ({
|
||||
WebhooksPage: vi.fn(() => <div>WebhooksPageMock</div>),
|
||||
}));
|
||||
|
||||
describe("WebhooksIntegrationPage", () => {
|
||||
test("renders WebhooksPage component", () => {
|
||||
render(<WebhooksPage params={{ environmentId: "test-env-id" }} />);
|
||||
expect(WebhooksPage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { ZId } from "@formbricks/types/common";
|
||||
import { ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(ZUserUpdateInput.partial())
|
||||
.schema(ZUserUpdateInput.pick({ name: true, locale: true }))
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
return await updateUser(ctx.user.id, parsedInput);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import EnterpriseSettingsPage from "./page";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
|
||||
});
|
||||
|
||||
test("renders correctly for an owner when not on Formbricks Cloud", async () => {
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: EnterpriseSettingsPage } = await import("./page");
|
||||
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
|
||||
render(Page);
|
||||
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
|
||||
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -118,7 +118,7 @@ const Page = async (props) => {
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
aria-hidden="true">
|
||||
<circle
|
||||
cx={512}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SettingsCard = ({
|
||||
id={title}>
|
||||
<div className="border-b border-slate-200 px-4 pb-4">
|
||||
<div className="flex">
|
||||
<h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3>
|
||||
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
|
||||
<div className="ml-2">
|
||||
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
||||
{soon && (
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand response"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex"
|
||||
onClick={handleCellClick}>
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { processResponseData } from "@/lib/responses";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { AnyActionArg } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
|
||||
import {
|
||||
@@ -257,3 +261,238 @@ describe("generateResponseTableColumns", () => {
|
||||
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("includes verifiedEmailColumn when isVerifyEmailEnabled is true", () => {
|
||||
// Arrange
|
||||
const mockSurvey = {
|
||||
questions: [],
|
||||
variables: [],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
isVerifyEmailEnabled: true,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockT = vi.fn((key) => key);
|
||||
const isExpanded = false;
|
||||
const isReadOnly = false;
|
||||
|
||||
// Act
|
||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
|
||||
|
||||
// Assert
|
||||
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
||||
expect(verifiedEmailColumn).toBeDefined();
|
||||
expect(verifiedEmailColumn?.accessorKey).toBe("verifiedEmail");
|
||||
|
||||
// Call the header function to trigger the t function call with "common.verified_email"
|
||||
if (verifiedEmailColumn && typeof verifiedEmailColumn.header === "function") {
|
||||
verifiedEmailColumn.header();
|
||||
expect(mockT).toHaveBeenCalledWith("common.verified_email");
|
||||
}
|
||||
});
|
||||
|
||||
test("excludes verifiedEmailColumn when isVerifyEmailEnabled is false", () => {
|
||||
// Arrange
|
||||
const mockSurvey = {
|
||||
questions: [],
|
||||
variables: [],
|
||||
hiddenFields: { fieldIds: [] },
|
||||
isVerifyEmailEnabled: false,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockT = vi.fn((key) => key);
|
||||
const isExpanded = false;
|
||||
const isReadOnly = false;
|
||||
|
||||
// Act
|
||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
|
||||
|
||||
// Assert
|
||||
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
||||
expect(verifiedEmailColumn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResponseTableColumns - Column Implementations", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("dateColumn renders with formatted date", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
|
||||
expect(dateColumn).toBeDefined();
|
||||
|
||||
// Call the header function to test it returns the expected value
|
||||
expect(dateColumn?.header?.()).toBe("common.date");
|
||||
|
||||
// Mock a response with a date to test the cell function
|
||||
const mockRow = {
|
||||
original: { createdAt: "2023-01-01T12:00:00Z" },
|
||||
} as any;
|
||||
|
||||
// Call the cell function and check the formatted date
|
||||
dateColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(vi.mocked(getFormattedDateTimeString)).toHaveBeenCalledWith(new Date("2023-01-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
test("personColumn renders anonymous when person is null", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
||||
expect(personColumn).toBeDefined();
|
||||
|
||||
// Test header content
|
||||
const headerResult = personColumn?.header?.();
|
||||
expect(headerResult).toBeDefined();
|
||||
|
||||
// Mock a response with no person
|
||||
const mockRow = {
|
||||
original: { person: null },
|
||||
} as any;
|
||||
|
||||
// Mock the t function for this specific call
|
||||
t.mockReturnValueOnce("Anonymous User");
|
||||
|
||||
// Call the cell function and check it returns "Anonymous"
|
||||
const cellResult = personColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(t).toHaveBeenCalledWith("common.anonymous");
|
||||
expect(cellResult?.props?.children).toBe("Anonymous User");
|
||||
});
|
||||
|
||||
test("personColumn renders person identifier when person exists", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
||||
expect(personColumn).toBeDefined();
|
||||
|
||||
// Mock a response with a person
|
||||
const mockRow = {
|
||||
original: {
|
||||
person: { id: "123", attributes: { email: "test@example.com" } },
|
||||
contactAttributes: { name: "John Doe" },
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
personColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(vi.mocked(getContactIdentifier)).toHaveBeenCalledWith(
|
||||
mockRow.original.person,
|
||||
mockRow.original.contactAttributes
|
||||
);
|
||||
});
|
||||
|
||||
test("tagsColumn returns undefined when tags is not an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
|
||||
expect(tagsColumn).toBeDefined();
|
||||
|
||||
// Mock a response with no tags
|
||||
const mockRow = {
|
||||
original: { tags: null },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = tagsColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult).toBeUndefined();
|
||||
});
|
||||
|
||||
test("notesColumn renders when notes is an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesColumn).toBeDefined();
|
||||
|
||||
// Mock a response with notes
|
||||
const mockRow = {
|
||||
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
notesColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
|
||||
});
|
||||
|
||||
test("notesColumn returns undefined when notes is not an array", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||
expect(notesColumn).toBeDefined();
|
||||
|
||||
// Mock a response with no notes
|
||||
const mockRow = {
|
||||
original: { notes: null },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult).toBeUndefined();
|
||||
});
|
||||
|
||||
test("variableColumns render variable values correctly", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
// Find the variable column for var1
|
||||
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
|
||||
expect(var1Column).toBeDefined();
|
||||
|
||||
// Test the header
|
||||
const headerResult = var1Column?.header?.();
|
||||
expect(headerResult).toBeDefined();
|
||||
|
||||
// Mock a response with a string variable
|
||||
const mockRow = {
|
||||
original: { variables: { var1: "Test Value" } },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = var1Column?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult?.props.children).toBe("Test Value");
|
||||
|
||||
// Test with a number variable
|
||||
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
|
||||
expect(var2Column).toBeDefined();
|
||||
|
||||
const mockRowNumber = {
|
||||
original: { variables: { var2: 42 } },
|
||||
} as any;
|
||||
|
||||
const cellResultNumber = var2Column?.cell?.({ row: mockRowNumber } as any);
|
||||
expect(cellResultNumber?.props.children).toBe(42);
|
||||
});
|
||||
|
||||
test("hiddenFieldColumns render when fieldIds exist", () => {
|
||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||
|
||||
// Find the hidden field column
|
||||
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
expect(hfColumn).toBeDefined();
|
||||
|
||||
// Test the header
|
||||
const headerResult = hfColumn?.header?.();
|
||||
expect(headerResult).toBeDefined();
|
||||
|
||||
// Mock a response with a hidden field value
|
||||
const mockRow = {
|
||||
original: { responseData: { hf1: "Hidden Value" } },
|
||||
} as any;
|
||||
|
||||
// Call the cell function
|
||||
const cellResult = hfColumn?.cell?.({ row: mockRow } as any);
|
||||
expect(cellResult?.props.children).toBe("Hidden Value");
|
||||
});
|
||||
|
||||
test("hiddenFieldColumns are empty when fieldIds don't exist", () => {
|
||||
// Create a survey with no hidden field IDs
|
||||
const surveyWithNoHiddenFields = {
|
||||
...mockSurvey,
|
||||
hiddenFields: { enabled: true }, // no fieldIds
|
||||
};
|
||||
|
||||
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
|
||||
|
||||
// Check that no hidden field columns were created
|
||||
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||
expect(hfColumn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
|
||||
@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute top-0 right-0 m-2">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<table className="mx-auto border-collapse cursor-default text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="text-center font-medium">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
|
||||
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<tbody>
|
||||
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||
<tr key={rowLabel}>
|
||||
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
|
||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
|
||||
@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
@@ -72,7 +72,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4 pb-4">
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
|
||||
@@ -26,7 +26,7 @@ export const QuestionSummaryHeader = ({
|
||||
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||
{formatTextWithSlashes(
|
||||
|
||||
@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
|
||||
@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center font-semibold whitespace-pre-wrap">
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
<div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
|
||||
<div className="pl-6 text-center md:px-6">
|
||||
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
|
||||
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
}, [surveyUrl]);
|
||||
}, [surveyUrl, t]);
|
||||
|
||||
const downloadQRCode = () => {
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,7 +96,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent",
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
!disabled ? "cursor-pointer" : "opacity-50"
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
OptionsType,
|
||||
QuestionOption,
|
||||
QuestionOptions,
|
||||
QuestionsComboBox,
|
||||
SelectedCommandItem,
|
||||
} from "./QuestionsComboBox";
|
||||
|
||||
describe("QuestionsComboBox", () => {
|
||||
afterEach(() => {
|
||||
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
|
||||
});
|
||||
});
|
||||
|
||||
describe("SelectedCommandItem", () => {
|
||||
test("renders question icon and color for QUESTIONS with questionType", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem
|
||||
label="Q1"
|
||||
type={OptionsType.QUESTIONS}
|
||||
questionType={TSurveyQuestionTypeEnum.OpenText}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Q1");
|
||||
});
|
||||
|
||||
test("renders attribute icon and color for ATTRIBUTES", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Attr" type={OptionsType.ATTRIBUTES} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Attr");
|
||||
});
|
||||
|
||||
test("renders hidden field icon and color for HIDDEN_FIELDS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Hidden" type={OptionsType.HIDDEN_FIELDS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Hidden");
|
||||
});
|
||||
|
||||
test("renders meta icon and color for META with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="device" type={OptionsType.META} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("device");
|
||||
});
|
||||
|
||||
test("renders other icon and color for OTHERS with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Language" type={OptionsType.OTHERS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Language");
|
||||
});
|
||||
|
||||
test("renders tag icon and color for TAGS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Tag1" type={OptionsType.TAGS} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Tag1");
|
||||
});
|
||||
|
||||
test("renders fallback color and no icon for unknown type", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Unknown" type={"UNKNOWN"} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).not.toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Unknown");
|
||||
});
|
||||
|
||||
test("renders fallback for non-string label", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem label={{ default: "NonString" }} type={OptionsType.QUESTIONS} />
|
||||
);
|
||||
expect(container.textContent).toContain("NonString");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
HelpCircleIcon,
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
LanguagesIcon,
|
||||
ListIcon,
|
||||
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
|
||||
onChangeValue: (option: QuestionOption) => void;
|
||||
}
|
||||
|
||||
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
switch (type) {
|
||||
case OptionsType.QUESTIONS:
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
return <StarIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <HelpCircleIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
return <ListIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
return <Rows3Icon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return <CheckIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return <ImageIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
return <GridIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
return <ListOrderedIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return <User width={18} height={18} className="text-white" />;
|
||||
const questionIcons = {
|
||||
// questions
|
||||
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return <EyeOff width={18} height={18} className="text-white" />;
|
||||
case OptionsType.META:
|
||||
switch (label) {
|
||||
case "device":
|
||||
return <SmartphoneIcon width={18} height={18} className="text-white" />;
|
||||
case "os":
|
||||
return <AirplayIcon width={18} height={18} className="text-white" />;
|
||||
case "browser":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "source":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "action":
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.OTHERS:
|
||||
switch (label) {
|
||||
case "Language":
|
||||
return <LanguagesIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.TAGS:
|
||||
return <HashIcon width={18} height={18} className="text-white" />;
|
||||
// attributes
|
||||
[OptionsType.ATTRIBUTES]: User,
|
||||
|
||||
// hidden fields
|
||||
[OptionsType.HIDDEN_FIELDS]: EyeOff,
|
||||
|
||||
// meta
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: GlobeIcon,
|
||||
action: MousePointerClickIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
|
||||
// tags
|
||||
[OptionsType.TAGS]: HashIcon,
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
if (type) {
|
||||
if (type === OptionsType.QUESTIONS && questionType) {
|
||||
return getIcon(questionType);
|
||||
} else if (type === OptionsType.ATTRIBUTES) {
|
||||
return getIcon(OptionsType.ATTRIBUTES);
|
||||
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,7 +166,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useParams } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { ResponseFilter } from "./ResponseFilter";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
|
||||
getSurveyFilterDataAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
generateQuestionAndFilterOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [[vi.fn()]],
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionsComboBox", () => ({
|
||||
QuestionsComboBox: ({ onChangeValue }) => (
|
||||
<div data-testid="questions-combo-box">
|
||||
<button onClick={() => onChangeValue({ id: "q1", label: "Question 1", type: "OpenText" })}>
|
||||
Select Question
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
OptionsType: {
|
||||
QUESTIONS: "Questions",
|
||||
ATTRIBUTES: "Attributes",
|
||||
TAGS: "Tags",
|
||||
LANGUAGES: "Languages",
|
||||
},
|
||||
}));
|
||||
|
||||
// Update the mock for QuestionFilterComboBox to always render
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox",
|
||||
() => ({
|
||||
QuestionFilterComboBox: () => (
|
||||
<div data-testid="filter-combo-box">
|
||||
<button data-testid="select-filter-btn">Select Filter</button>
|
||||
<button data-testid="select-filter-type-btn">Select Filter Type</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
describe("ResponseFilter", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockSelectedFilter = {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
};
|
||||
|
||||
const mockSelectedOptions = {
|
||||
questionFilterOptions: [
|
||||
{
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
filterOptions: ["equals", "does not equal"],
|
||||
filterComboBoxOptions: [],
|
||||
id: "q1",
|
||||
},
|
||||
],
|
||||
questionOptions: [
|
||||
{
|
||||
label: "Questions",
|
||||
type: "Questions",
|
||||
option: [
|
||||
{ id: "q1", label: "Question 1", type: "OpenText", questionType: TSurveyQuestionTypeEnum.OpenText },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const mockSetSelectedFilter = vi.fn();
|
||||
const mockSetSelectedOptions = vi.fn();
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
environmentId: "env1",
|
||||
name: "Test Survey",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
createdBy: "user1",
|
||||
questions: [],
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
triggers: [],
|
||||
displayOption: "displayOnce",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: mockSelectedFilter,
|
||||
setSelectedFilter: mockSetSelectedFilter,
|
||||
selectedOptions: mockSelectedOptions,
|
||||
setSelectedOptions: mockSetSelectedOptions,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useParams).mockReturnValue({ environmentId: "env1", surveyId: "survey1" });
|
||||
|
||||
vi.mocked(getSurveyFilterDataAction).mockResolvedValue({
|
||||
data: {
|
||||
attributes: [],
|
||||
meta: {},
|
||||
environmentTags: [],
|
||||
hiddenFields: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
vi.mocked(generateQuestionAndFilterOptions).mockReturnValue({
|
||||
questionFilterOptions: mockSelectedOptions.questionFilterOptions,
|
||||
questionOptions: mockSelectedOptions.questionOptions,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders with default state", () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
expect(screen.getByText("Filter")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the filter popover when clicked", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("fetches filter data when opened", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
expect(getSurveyFilterDataAction).toHaveBeenCalledWith({ surveyId: "survey1" });
|
||||
expect(mockSetSelectedOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles adding new filter", async () => {
|
||||
// Start with an empty filter
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: { filter: [], onlyComplete: false },
|
||||
setSelectedFilter: mockSetSelectedFilter,
|
||||
selectedOptions: mockSelectedOptions,
|
||||
setSelectedOptions: mockSetSelectedOptions,
|
||||
} as any);
|
||||
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
// Verify there's no filter yet
|
||||
expect(screen.queryByTestId("questions-combo-box")).not.toBeInTheDocument();
|
||||
|
||||
// Add a new filter and check that the questions combo box appears
|
||||
await userEvent.click(screen.getByText("common.add_filter"));
|
||||
|
||||
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles only complete checkbox toggle", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
await userEvent.click(screen.getByRole("checkbox"));
|
||||
await userEvent.click(screen.getByText("common.apply_filters"));
|
||||
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
|
||||
});
|
||||
|
||||
test("handles selecting question and filter options", async () => {
|
||||
// Setup with a pre-populated filter to ensure the filter components are rendered
|
||||
const setSelectedFilterMock = vi.fn();
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: {
|
||||
filter: [
|
||||
{
|
||||
questionType: { id: "q1", label: "Question 1", type: "OpenText" },
|
||||
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
|
||||
},
|
||||
],
|
||||
onlyComplete: false,
|
||||
},
|
||||
setSelectedFilter: setSelectedFilterMock,
|
||||
selectedOptions: mockSelectedOptions,
|
||||
setSelectedOptions: mockSetSelectedOptions,
|
||||
} as any);
|
||||
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
// Verify both combo boxes are rendered
|
||||
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("filter-combo-box")).toBeInTheDocument();
|
||||
|
||||
// Use data-testid to find our buttons instead of text
|
||||
await userEvent.click(screen.getByText("Select Question"));
|
||||
await userEvent.click(screen.getByTestId("select-filter-btn"));
|
||||
await userEvent.click(screen.getByText("common.apply_filters"));
|
||||
|
||||
expect(setSelectedFilterMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles clear all filters", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
await userEvent.click(screen.getByText("common.clear_all"));
|
||||
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
|
||||
});
|
||||
|
||||
test("uses sharing key action when on sharing page", async () => {
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
environmentId: "env1",
|
||||
surveyId: "survey1",
|
||||
sharingKey: "share123",
|
||||
});
|
||||
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
|
||||
data: {
|
||||
attributes: [],
|
||||
meta: {},
|
||||
environmentTags: [],
|
||||
hiddenFields: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
|
||||
sharingKey: "share123",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
});
|
||||
35
apps/web/app/(auth)/auth/forgot-password/page.test.tsx
Normal file
35
apps/web/app/(auth)/auth/forgot-password/page.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import ForgotPasswordPage from "./page";
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/page", () => ({
|
||||
ForgotPasswordPage: () => (
|
||||
<div data-testid="forgot-password-page">
|
||||
<div data-testid="form-wrapper">
|
||||
<div data-testid="forgot-password-form">Forgot Password Form</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ForgotPasswordPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the forgot password page", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the form wrapper", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the forgot password form", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
74
apps/web/app/ClientEnvironmentRedirect.test.tsx
Normal file
74
apps/web/app/ClientEnvironmentRedirect.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import ClientEnvironmentRedirect from "./ClientEnvironmentRedirect";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ClientEnvironmentRedirect", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should redirect to the provided environment ID when no last environment exists", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
|
||||
});
|
||||
|
||||
test("should redirect to the last environment ID when it exists in localStorage", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage with a last environment ID
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue("last-env-id"),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
|
||||
});
|
||||
|
||||
test("should update redirect when environment ID prop changes", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
|
||||
|
||||
// Clear mock calls
|
||||
mockPush.mockClear();
|
||||
|
||||
// Rerender with new environment ID
|
||||
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
import {
|
||||
mockContactEmailFollowUp,
|
||||
mockDirectEmailFollowUp,
|
||||
mockEndingFollowUp,
|
||||
mockEndingId2,
|
||||
mockResponse,
|
||||
mockResponseEmailFollowUp,
|
||||
mockResponseWithContactQuestion,
|
||||
mockSurvey,
|
||||
mockSurveyWithContactQuestion,
|
||||
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendFollowUpEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Survey Follow Up", () => {
|
||||
const mockOrganization: Partial<TOrganization> = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
whitelabel: {
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluateFollowUp", () => {
|
||||
test("sends email when to is a direct email address", async () => {
|
||||
const followUpId = mockDirectEmailFollowUp.id;
|
||||
const followUpAction = mockDirectEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockDirectEmailFollowUp.action.properties.body,
|
||||
subject: mockDirectEmailFollowUp.action.properties.subject,
|
||||
to: mockDirectEmailFollowUp.action.properties.to,
|
||||
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockResponseEmailFollowUp.action.properties.body,
|
||||
subject: mockResponseEmailFollowUp.action.properties.subject,
|
||||
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
|
||||
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email in array", async () => {
|
||||
const followUpId = mockContactEmailFollowUp.id;
|
||||
const followUpAction = mockContactEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurveyWithContactQuestion,
|
||||
mockResponseWithContactQuestion,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockContactEmailFollowUp.action.properties.body,
|
||||
subject: mockContactEmailFollowUp.action.properties.subject,
|
||||
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
|
||||
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurveyWithContactQuestion,
|
||||
response: mockResponseWithContactQuestion,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when to value is not found in response data", async () => {
|
||||
const followUpId = "followup1";
|
||||
const followUpAction = {
|
||||
...mockSurvey.followUps![0].action,
|
||||
properties: {
|
||||
...mockSurvey.followUps![0].action.properties,
|
||||
to: "nonExistentField",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
});
|
||||
|
||||
test("throws error when email address is invalid", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
const invalidResponse = {
|
||||
...mockResponse,
|
||||
data: {
|
||||
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
invalidResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSurveyFollowUps", () => {
|
||||
test("skips follow-up when ending Id doesn't match", async () => {
|
||||
const responseWithDifferentEnding = {
|
||||
...mockResponse,
|
||||
endingId: mockEndingId2,
|
||||
};
|
||||
|
||||
const mockSurveyWithEndingFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockEndingFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithEndingFollowUp,
|
||||
responseWithDifferentEnding as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockEndingFollowUp.id,
|
||||
status: "skipped",
|
||||
},
|
||||
]);
|
||||
expect(sendFollowUpEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("processes follow-ups and log errors", async () => {
|
||||
const error = new Error("Test error");
|
||||
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
|
||||
|
||||
const mockSurveyWithFollowUps: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockResponseEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUps,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockResponseEmailFollowUp.id,
|
||||
status: "error",
|
||||
error: "Test error",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
|
||||
"Follow-up processing errors"
|
||||
);
|
||||
});
|
||||
|
||||
test("successfully processes follow-ups", async () => {
|
||||
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
|
||||
|
||||
const mockSurveyWithFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockDirectEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUp,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockDirectEmailFollowUp.id,
|
||||
status: "success",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
type FollowUpResult = {
|
||||
followUpId: string;
|
||||
status: "success" | "error" | "skipped";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const evaluateFollowUp = async (
|
||||
followUpId: string,
|
||||
followUpAction: TSurveyFollowUpAction,
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<void> => {
|
||||
const { properties } = followUpAction;
|
||||
const { to, subject, body, replyTo } = properties;
|
||||
const toValueFromResponse = response.data[to];
|
||||
const logoUrl = organization.whitelabel?.logoUrl || "";
|
||||
|
||||
// Check if 'to' is a direct email address (team member or user email)
|
||||
const parsedEmailTo = z.string().email().safeParse(to);
|
||||
if (parsedEmailTo.success) {
|
||||
// 'to' is a valid email address, send email directly
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedEmailTo.data,
|
||||
replyTo,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
logoUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a direct email, check if it's a question ID or hidden field ID
|
||||
if (!toValueFromResponse) {
|
||||
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
|
||||
if (typeof toValueFromResponse === "string") {
|
||||
// parse this string to check for an email:
|
||||
const parsedResult = z.string().email().safeParse(toValueFromResponse);
|
||||
if (parsedResult.data) {
|
||||
// send email to this email address
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
} else if (Array.isArray(toValueFromResponse)) {
|
||||
const emailAddress = toValueFromResponse[2];
|
||||
if (!emailAddress) {
|
||||
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
const parsedResult = z.string().email().safeParse(emailAddress);
|
||||
if (parsedResult.data) {
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sendSurveyFollowUps = async (
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<FollowUpResult[]> => {
|
||||
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
|
||||
const { trigger } = followUp;
|
||||
|
||||
// Check if we should skip this follow-up based on ending IDs
|
||||
if (trigger.properties) {
|
||||
const { endingIds } = trigger.properties;
|
||||
const { endingId } = response;
|
||||
|
||||
if (!endingId || !endingIds.includes(endingId)) {
|
||||
return Promise.resolve({
|
||||
followUpId: followUp.id,
|
||||
status: "skipped",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
|
||||
.then(() => ({
|
||||
followUpId: followUp.id,
|
||||
status: "success" as const,
|
||||
}))
|
||||
.catch((error) => ({
|
||||
followUpId: followUp.id,
|
||||
status: "error" as const,
|
||||
error: error instanceof Error ? error.message : "Something went wrong",
|
||||
}));
|
||||
});
|
||||
|
||||
const followUpResults = await Promise.all(followUpPromises);
|
||||
|
||||
// Log all errors
|
||||
const errors = followUpResults
|
||||
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
|
||||
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.error(errors, "Follow-up processing errors");
|
||||
}
|
||||
|
||||
return followUpResults;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
|
||||
select: { email: true, locale: true },
|
||||
});
|
||||
|
||||
// send follow up emails
|
||||
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
|
||||
if (surveyFollowUpsPermission) {
|
||||
await sendSurveyFollowUps(survey, response, organization);
|
||||
if (survey.followUps?.length > 0) {
|
||||
// send follow up emails
|
||||
const followUpsResult = await sendFollowUpsForResponse(response.id);
|
||||
if (!followUpsResult.ok) {
|
||||
const { error: followUpsError } = followUpsResult;
|
||||
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
|
||||
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailPromises = usersWithNotifications.map((user) =>
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("getEnvironmentState", () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue([mockSurveys[0]]); // Only return the app, inProgress survey
|
||||
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
|
||||
});
|
||||
|
||||
@@ -99,12 +99,8 @@ export const getEnvironmentState = async (
|
||||
getActionClassesForEnvironmentState(environmentId),
|
||||
]);
|
||||
|
||||
const filteredSurveys = surveys.filter(
|
||||
(survey) => survey.type === "app" && survey.status === "inProgress"
|
||||
);
|
||||
|
||||
const data: TJsEnvironmentState["data"] = {
|
||||
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
|
||||
surveys: !isMonthlyResponsesLimitReached ? surveys : [],
|
||||
actionClasses,
|
||||
project: project,
|
||||
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
|
||||
|
||||
@@ -100,8 +100,14 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
select: expect.any(Object), // Check if select is called, specific fields are in the original code
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
|
||||
expect(result).toEqual([mockTransformedSurvey]);
|
||||
@@ -114,8 +120,14 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
const result = await getSurveysForEnvironmentState(environmentId);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user